/** * Plugin Storage Index Management * * Manages expression indexes on the _plugin_storage table for efficient queries. * * @see PLUGIN-SYSTEM.md ยง Plugin Storage > Index Management */ import type { Kysely, RawBuilder } from "kysely"; import { sql } from "kysely"; import { jsonExtractExpr, isPostgres } from "../database/dialect-helpers.js"; import type { Database } from "../database/types.js"; import { validateIdentifier, validateJsonFieldName, validatePluginIdentifier, } from "../database/validate.js"; /** * Generate a deterministic index name. * Unique indexes use a `uidx_` prefix to avoid collisions with regular indexes on the same fields. */ export function generateIndexName( pluginId: string, collection: string, fields: string[], options?: { unique?: boolean }, ): string { const prefix = options?.unique ? "uidx" : "idx"; const fieldPart = fields.join("_"); // SQLite index names have no length limit, but keep it reasonable return `${prefix}_plugin_${pluginId}_${collection}_${fieldPart}`.substring(0, 128); } /** * Generate a Kysely sql expression for creating an expression index. * * Validates all identifiers before interpolation to prevent SQL injection. * Plugin ID and collection values are parameterized in the WHERE clause. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance export function generateCreateIndexSql( db: Kysely, pluginId: string, collection: string, fields: string[], options?: { unique?: boolean }, ): RawBuilder { // Validate all identifiers validatePluginIdentifier(pluginId, "plugin ID"); validateIdentifier(collection, "collection name"); for (const field of fields) { validateJsonFieldName(field, "index field name"); } const indexName = generateIndexName(pluginId, collection, fields, options); // Build the indexed expressions // Fields are validated above, safe to interpolate into json path const expressions = fields .map((field) => { if (isPostgres(db)) { // Postgres expression indexes need parens around the expression return `(${jsonExtractExpr(db, "data", field)})`; } return jsonExtractExpr(db, "data", field); }) .join(", "); // Partial index filtered to this plugin/collection // SQLite prohibits bound parameters in partial index WHERE clauses, // so we use sql.lit() for literal string values. Both pluginId and // collection are validated above, so this is safe. const createKeyword = options?.unique ? "CREATE UNIQUE INDEX" : "CREATE INDEX"; return sql`${sql.raw(createKeyword)} IF NOT EXISTS ${sql.ref(indexName)} ON _plugin_storage(${sql.raw(expressions)}) WHERE plugin_id = ${sql.lit(pluginId)} AND collection = ${sql.lit(collection)} `; } /** * Generate a Kysely sql expression for dropping an index. * * Uses sql.ref() for safe identifier quoting. */ export function generateDropIndexSql(indexName: string): RawBuilder { return sql`DROP INDEX IF EXISTS ${sql.ref(indexName)}`; } /** * Normalize index declarations to arrays of field arrays */ export function normalizeIndexes(indexes: Array): string[][] { return indexes.map((index) => (Array.isArray(index) ? index : [index])); } /** * Create all declared indexes for a plugin collection */ export async function createStorageIndexes( db: Kysely, pluginId: string, collection: string, indexes: Array, options?: { uniqueIndexes?: Array }, ): Promise<{ created: string[]; errors: Array<{ index: string; error: string }>; }> { const normalized = normalizeIndexes(indexes); const uniqueNormalized = options?.uniqueIndexes ? normalizeIndexes(options.uniqueIndexes) : []; const uniqueSet = new Set(uniqueNormalized.map((f) => f.join(","))); // Deduplicate: if fields appear in both indexes and uniqueIndexes, only create the unique version const deduped = normalized.filter((f) => !uniqueSet.has(f.join(","))); const allEntries: Array<{ fields: string[]; unique: boolean }> = [ ...deduped.map((fields) => ({ fields, unique: false })), ...uniqueNormalized.map((fields) => ({ fields, unique: true })), ]; const created: string[] = []; const errors: Array<{ index: string; error: string }> = []; for (const entry of allEntries) { const { fields } = entry; const indexName = generateIndexName(pluginId, collection, fields, { unique: entry.unique }); try { // Create the index const createSql = generateCreateIndexSql(db, pluginId, collection, fields, { unique: entry.unique, }); await createSql.execute(db); // Track in _plugin_indexes table await db .insertInto("_plugin_indexes") .values({ plugin_id: pluginId, collection, index_name: indexName, fields: JSON.stringify(fields), }) .onConflict((oc) => oc .columns(["plugin_id", "collection", "index_name"]) .doUpdateSet({ fields: JSON.stringify(fields) }), ) .execute(); created.push(indexName); } catch (error) { errors.push({ index: indexName, error: error instanceof Error ? error.message : String(error), }); } } return { created, errors }; } /** * Remove indexes that are no longer declared */ export async function removeOrphanedIndexes( db: Kysely, pluginId: string, collection: string, currentIndexes: Array, options?: { uniqueIndexes?: Array }, ): Promise<{ removed: string[]; errors: Array<{ index: string; error: string }>; }> { const normalized = normalizeIndexes(currentIndexes); const uniqueNormalized = options?.uniqueIndexes ? normalizeIndexes(options.uniqueIndexes) : []; const uniqueSet = new Set(uniqueNormalized.map((f) => f.join(","))); // Build the set of expected index names (regular + unique with correct prefix) const currentIndexNames = new Set(); for (const fields of normalized) { // If this field set is in both, only the unique version exists (deduplication in create) if (!uniqueSet.has(fields.join(","))) { currentIndexNames.add(generateIndexName(pluginId, collection, fields)); } } for (const fields of uniqueNormalized) { currentIndexNames.add(generateIndexName(pluginId, collection, fields, { unique: true })); } // Get existing indexes from tracking table const existingIndexes = await db .selectFrom("_plugin_indexes") .select(["index_name"]) .where("plugin_id", "=", pluginId) .where("collection", "=", collection) .execute(); const removed: string[] = []; const errors: Array<{ index: string; error: string }> = []; for (const { index_name } of existingIndexes) { if (!currentIndexNames.has(index_name)) { try { // Drop the index await generateDropIndexSql(index_name).execute(db); // Remove from tracking table await db .deleteFrom("_plugin_indexes") .where("plugin_id", "=", pluginId) .where("collection", "=", collection) .where("index_name", "=", index_name) .execute(); removed.push(index_name); } catch (error) { errors.push({ index: index_name, error: error instanceof Error ? error.message : String(error), }); } } } return { removed, errors }; } /** * Sync indexes for a plugin collection (create new, remove old) */ export async function syncStorageIndexes( db: Kysely, pluginId: string, collection: string, indexes: Array, options?: { uniqueIndexes?: Array }, ): Promise<{ created: string[]; removed: string[]; errors: Array<{ index: string; error: string }>; }> { const [createResult, removeResult] = await Promise.all([ createStorageIndexes(db, pluginId, collection, indexes, options), removeOrphanedIndexes(db, pluginId, collection, indexes, options), ]); return { created: createResult.created, removed: removeResult.removed, errors: [...createResult.errors, ...removeResult.errors], }; } /** * Remove all indexes for a plugin */ export async function removeAllPluginIndexes( db: Kysely, pluginId: string, ): Promise<{ removed: string[]; errors: Array<{ index: string; error: string }>; }> { const existingIndexes = await db .selectFrom("_plugin_indexes") .select(["index_name", "collection"]) .where("plugin_id", "=", pluginId) .execute(); const removed: string[] = []; const errors: Array<{ index: string; error: string }> = []; for (const { index_name } of existingIndexes) { try { await generateDropIndexSql(index_name).execute(db); removed.push(index_name); } catch (error) { errors.push({ index: index_name, error: error instanceof Error ? error.message : String(error), }); } } // Clean up tracking table await db.deleteFrom("_plugin_indexes").where("plugin_id", "=", pluginId).execute(); return { removed, errors }; } /** * Get current index status for a plugin */ export async function getPluginIndexStatus( db: Kysely, pluginId: string, ): Promise< Array<{ collection: string; indexName: string; fields: string[]; createdAt: string; }> > { const rows = await db .selectFrom("_plugin_indexes") .select(["collection", "index_name", "fields", "created_at"]) .where("plugin_id", "=", pluginId) .execute(); return rows.map((row) => { const parsed: unknown = JSON.parse(row.fields); const fields = Array.isArray(parsed) ? parsed.filter((f): f is string => typeof f === "string") : []; return { collection: row.collection, indexName: row.index_name, fields, createdAt: row.created_at, }; }); }