import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index"; import type { Config } from "../config.ts"; import { ArrayQueryCollection, type LuaCollectionQuery, type LuaQueryCollection, } from "../space_lua/query_collection.ts"; import { jsToLuaValue, LuaEnv, LuaStackFrame, LuaTable, } from "../space_lua/runtime.ts"; import type { DataStore } from "./datastore.ts"; import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore"; import type { EventHook } from "../plugos/hooks/event.ts"; import type { DataStoreMQ } from "./mq.datastore.ts"; import type { Space } from "../space.ts"; import { validateObject } from "../plugos/syscalls/jsonschema.ts"; import { getAggregateSpec, getBuiltinAggregateEntries, } from "../space_lua/aggregates.ts"; const indexKey = "idx"; const pageKey = "ridx"; const indexVersionKey = ["$indexVersion"]; // Bump this one every time a full reindex is needed const desiredIndexVersion = 9; type TagDefinition = { metatable?: any; mustValidate?: boolean; schema?: any; validate?: (o: ObjectValue) => Promise; transform?: ( o: ObjectValue, ) => | Promise | ObjectValue[] | ObjectValue | null; }; export class ObjectValidationError extends Error { constructor( message: string, readonly object: ObjectValue, ) { super(message); } } export class ObjectIndex { constructor( private ds: DataStore, private config: Config, private eventHook: EventHook, private mq: DataStoreMQ, ) { // Clear any entries for deleted files this.eventHook.addLocalListener("file:deleted", (path: string) => { return this.clearFileIndex(path); }); // Tracks if the file:listed event has been triggered, // which is fired after all file:changed events have been dispatched // resulting in new index entries (if any) being queued in the index queue // this is later used to track if the index is complete let indexStarted = false; this.eventHook.addLocalListener("file:listed", () => { indexStarted = true; }); // Handle initial index completion void this.hasFullIndexCompleted().then((hasCompleted) => { if (!hasCompleted) { const emptyQueueHandler = async () => { console.log("Index queue empty, checking if index is complete"); // Theoretically we could get empty queue notifications before the file:listed event has been triggered, so let's account for this if (indexStarted) { // Indexing has just finished for the first time for this client console.info("Initial index complete, reloading editor state"); await this.markFullIndexComplete(); // Unsubscribe yourself this.eventHook.removeLocalListener( "mq:emptyQueue:indexQueue", emptyQueueHandler, ); // Trigger an editor:reloadState event to reload the editor state (render widgets etc.) void this.eventHook.dispatchEvent("editor:reloadState"); } }; this.eventHook.addLocalListener( "mq:emptyQueue:indexQueue", emptyQueueHandler, ); } }); } tag(tagName: string): LuaQueryCollection { if (!tagName) { throw new Error("Tag name is required"); } return { query: ( query: LuaCollectionQuery, env: LuaEnv, sf: LuaStackFrame, config?: Config, ): Promise => { return this.ds.luaQuery( ["idx", tagName], query, env, sf, (key, value: any) => { const tag = key[1]; const mt = this.config.get( ["tags", tag, "metatable"], undefined, ); if (!mt) { // Return as is return value; } // Convert to LuaTable value = jsToLuaValue(value); value.metatable = mt; return value; }, config, ); }, }; } /** * Returns a queryable collection of all aggregate functions: * * - builtin, * - user-defined, and * - aliases. * * Every row has all columns: `builtin`, `name`, `description`, * `initialize`, `iterate`, `finish` and `target`. */ aggregates(): LuaQueryCollection { const entries: Record[] = []; // Builtins are always listed (even if overridden) for (const entry of getBuiltinAggregateEntries()) { entries.push({ builtin: true, name: entry.name, description: entry.description, initialize: true, iterate: true, finish: entry.hasFinish, target: null, }); } // Config entries (user-defined overrides and aliases) const userAggs: Record = this.config.get("aggregates", {}); for (const [key, spec] of Object.entries(userAggs)) { const aliasTarget = spec instanceof LuaTable ? spec.rawGet("alias") : spec?.alias ?? null; if (typeof aliasTarget === "string") { const resolved = getAggregateSpec(aliasTarget, this.config); entries.push({ builtin: false, name: key, description: spec instanceof LuaTable ? spec.rawGet("description") ?? resolved?.description ?? "" : spec?.description ?? resolved?.description ?? "", initialize: resolved ? !!resolved.initialize : false, iterate: resolved ? !!resolved.iterate : false, finish: resolved ? !!resolved.finish : false, target: aliasTarget, }); } else { let hasInit = false; let hasIter = false; let hasFin = false; let desc = ""; if (spec instanceof LuaTable) { hasInit = !!spec.rawGet("initialize"); hasIter = !!spec.rawGet("iterate"); hasFin = !!spec.rawGet("finish"); desc = spec.rawGet("description") ?? ""; } else if (spec) { hasInit = !!spec.initialize; hasIter = !!spec.iterate; hasFin = !!spec.finish; desc = spec.description ?? ""; } entries.push({ builtin: false, name: key, description: desc, initialize: hasInit, iterate: hasIter, finish: hasFin, target: null, }); } } return new ArrayQueryCollection(entries); } getObjectByRef(page: string, tag: string, ref: string) { return this.ds.get([indexKey, tag, this.cleanKey(ref, page), page]); } async ensureFullIndex(space: Space) { const currentIndexVersion = await this.getCurrentIndexVersion(); if (!currentIndexVersion) { console.log("No index version found, assuming fresh install"); return; } if ( // If the index version is less than the desired version currentIndexVersion < desiredIndexVersion && // And the index queue is empty (meaning no indexing is ongoing) (await this.mq.isQueueEmpty("indexQueue")) ) { console.info( "[index]", "Performing a full space reindex, this could take a while...", currentIndexVersion, desiredIndexVersion, ); await this.reindexSpace(space); // Dispatch an editor:reloadState event to reload the editor state (render widgets etc.) void this.eventHook.dispatchEvent("editor:reloadState"); } } async reindexSpace(space: Space) { console.log("Clearing page index..."); await this.clearIndex(); await this.markFullIndexInComplete(); const files = await space.deduplicatedFileList(); console.log("Queing", files.length, "pages to be indexed."); // Queue all file names to be indexed const startTime = Date.now(); await this.mq.batchSend( "indexQueue", files.map((file) => file.name), ); await this.mq.awaitEmptyQueue("indexQueue"); await this.markFullIndexComplete(); console.log("Full index completed after", Date.now() - startTime, "ms"); } public async hasFullIndexCompleted() { return (await this.ds.get(indexVersionKey)) >= desiredIndexVersion; } private getCurrentIndexVersion() { return this.ds.get(indexVersionKey); } async awaitIndexQueueDrain(): Promise { await this.mq.awaitEmptyQueue("indexQueue"); } async markFullIndexComplete() { await this.ds.set(indexVersionKey, desiredIndexVersion); } async markFullIndexInComplete() { await this.ds.delete(indexVersionKey); } cleanKey(ref: string, page: string) { if (ref.startsWith(`${page}@`)) { return ref.substring(page.length + 1); } else { return ref; } } queryLuaObjects( globalEnv: LuaEnv, tag: string, query: LuaCollectionQuery, scopedVariables?: Record, ): Promise[]> { const sf = LuaStackFrame.createWithGlobalEnv(globalEnv); let env = globalEnv; if (scopedVariables) { env = new LuaEnv(globalEnv); for (const [key, value] of Object.entries(scopedVariables)) { env.setLocal(key, jsToLuaValue(value)); } } return this.ds.luaQuery([indexKey, tag], query, env, sf); } batchSet(page: string, kvs: KV[]): Promise { const finalBatch: KV[] = []; for (const { key, value } of kvs) { finalBatch.push( { key: [indexKey, ...key, page], value, }, { key: [pageKey, page, ...key], value: true, }, ); } return this.ds.batchSet(finalBatch); } batchDelete(page: string, keys: KvKey[]): Promise { const finalBatch: KvKey[] = []; for (const key of keys) { finalBatch.push([indexKey, ...key, page]); } return this.ds.batchDelete(finalBatch); } /** * Clears all keys for a given file * @param file */ public async clearFileIndex(file: string): Promise { if (file.endsWith(".md")) { file = file.replace(/\.md$/, ""); } // console.log("Clearing index for", file); const allKeys: KvKey[] = []; for await (const { key } of this.ds.query({ prefix: [pageKey, file], })) { allKeys.push(key); allKeys.push([indexKey, ...key.slice(2), file]); } await this.ds.batchDelete(allKeys); } /** * Clears the entire index */ public async clearIndex(): Promise { const allKeys: KvKey[] = []; for await (const { key } of this.ds.query({ prefix: [indexKey] })) { allKeys.push(key); } for await (const { key } of this.ds.query({ prefix: [pageKey] })) { allKeys.push(key); } await this.ds.batchDelete(allKeys); console.log("Deleted", allKeys.length, "keys from the index"); } /** * Indexes entities in the data store */ public async indexObjects( page: string, objects: ObjectValue[], ): Promise { const kvs = await this.processObjectsToKVs(page, objects, false); if (kvs.length > 0) { return this.batchSet(page, kvs); } else { return Promise.resolve(); } } /** * Validate and transform objects, throws a ValidationError when it fails * @param page * @param objects * @throw ValidationError */ public async validateObjects(page: string, objects: ObjectValue[]) { await this.processObjectsToKVs(page, objects, true); } private async processObjectsToKVs( page: string, objects: ObjectValue[], throwOnValidationErrors: boolean, ): Promise[]> { const kvs: KV[] = []; const tagDefinitions: Record = this.config.get( "tags", {}, ); // Taking this iteration approach as new objects may be pushed into this array on the fly while (objects.length > 0) { const obj = objects.shift()!; if (!obj.tag) { console.error("Object has no tag", obj, "this shouldn't happen"); continue; } // Index as all the tag + any additional tags specified const allTags = [obj.tag, ...(obj.tags || [])]; for (const tag of allTags) { const tagDefinition = tagDefinitions[tag]; // Validate object based on schema if required if ( tagDefinition?.schema && (tagDefinition?.mustValidate || throwOnValidationErrors) ) { const validationError = validateObject(tagDefinition?.schema, obj); if (validationError) { if (!throwOnValidationErrors) { console.warn( `Object failed ${tag} validation so won't be indexed:`, obj, "Validation error:", validationError, ); continue; } else { throw new ObjectValidationError(validationError, obj); } } } // Validate object based on validate callback if required if ( tagDefinition?.validate && (tagDefinition?.mustValidate || throwOnValidationErrors) ) { const validationError = await tagDefinition.validate(obj); if (validationError) { if (!throwOnValidationErrors) { console.warn( `Object failed ${tag} validation so won't be indexed:`, obj, "Validation error:", validationError, ); continue; } else { throw new ObjectValidationError(validationError, obj); } } } // Transform object if (tagDefinition?.transform) { let newObjects; try { newObjects = await tagDefinition.transform(obj); } catch (e: any) { throw new ObjectValidationError(e.message, obj); } if (!newObjects) { // null value returned, just index as usual kvs.push({ key: [tag, this.cleanKey(obj.ref, page)], value: obj, }); continue; } if (!Array.isArray(newObjects)) { // Probably returned single object, let's normalize newObjects = [newObjects]; } // A transform function _must_ either return an empty list of objects to index, or return at least one object with the same ref // If this doesn't happen, we may end up in an infinite loop. let foundAssignedRef = false; for (const newObj of newObjects) { if (!newObj.ref) { console.error( "transform result object did not contain ref", newObj, ); continue; } if (newObj.ref === obj.ref) { // Got the same object back here, let's just index it without further processing kvs.push({ key: [tag, this.cleanKey(newObj.ref, page)], value: newObj, }); foundAssignedRef = true; } else { // Some other object objects.push(newObj); } } if (!foundAssignedRef && newObjects.length) { throw new Error( `transform() result objects for ${tag} did not contain result with original ref.`, ); } } else { // Just insert it directly kvs.push({ key: [tag, this.cleanKey(obj.ref, page)], value: obj, }); } } } return kvs; } deleteObject(page: string, tag: string, ref: string): Promise { return this.batchDelete(page, [[tag, this.cleanKey(ref, page)]]); } }