import { LRUCache } from "@commonfabric/utils/cache"; import { isDeepFrozen } from "@commonfabric/data-model/deep-freeze"; import { hashSchema, internSchema } from "@commonfabric/data-model/schema-hash"; import { schemaWithProperties } from "@commonfabric/data-model/schema-utils"; import type { FabricValue, SchemaPathSelector } from "@commonfabric/api"; import type { Result, Unit } from "@commonfabric/memory/interface"; import { isRecord } from "@commonfabric/utils/types"; import type { JSONSchema } from "../builder/types.ts"; import { ContextualFlowControl } from "../cfc.ts"; import { BaseMemoryAddress, MapSetStringToStrings } from "../traverse.ts"; import * as Address from "./transaction/address.ts"; const toKey = ({ id, scope }: BaseMemoryAddress) => `${scope ?? "space"}\0${id}`; const fromKey = (key: string): BaseMemoryAddress => { const separator = key.indexOf("\0"); if (separator === -1) { return { id: key as BaseMemoryAddress["id"], type: "application/json", }; } return { scope: key.slice(0, separator) as BaseMemoryAddress["scope"], id: key.slice(separator + 1) as BaseMemoryAddress["id"], type: "application/json", }; }; // Only cache by identity against already-deep-frozen inputs. Mutable schemas // can be edited in place, and keying this cache by their identity would // return stale results. const standardizedSchemaCache = new WeakMap(); // Standardization results for MUTABLE inputs, keyed by content hash. The hash // is recomputed per call (hashOf does not identity-cache mutable objects), so // in-place edits change the key and stay correct — while structurally-equal // fresh objects, the common case on the subscription path, pay one content // hash instead of a full rebuild + intern + re-hash each call. const standardizedByContentCache = new LRUCache({ capacity: 4096, }); // hashSchema(getStandardSchema(schema-without-$defs)) per interned schema // instance. getSupersetSelector compares this pair for every tracked selector // on every lookup; without the memo each comparison rebuilds the spread and // re-hashes both sides. const noDefsStandardHashCache = new WeakMap(); const noDefsStandardHash = (schema: Record): string => { let hash = noDefsStandardHashCache.get(schema); if (hash === undefined) { const { $defs: _defs, ...rest } = schema; hash = hashSchema(SelectorTracker.getStandardSchema(rest as JSONSchema)); noDefsStandardHashCache.set(schema, hash); } return hash; }; const selectorRefFor = (selector: SchemaPathSelector): string => JSON.stringify([ selector.path, selector.schema === undefined ? "" : hashSchema(SelectorTracker.getStandardSchema(selector.schema)), ]); // This class helps us maintain a client model of our server side subscriptions. export class SelectorTracker> { private refTracker = new MapSetStringToStrings(); private selectors = new Map(); private standardizedSelector = new Map(); private selectorPromises = new Map>(); add( address: BaseMemoryAddress, selector: SchemaPathSelector, promise: Promise, ) { if (selector === undefined || selector.schema === undefined) { return; } const selectorRef = selectorRefFor(selector); this.refTracker.add(toKey(address), selectorRef); this.selectors.set(selectorRef, selector); this.standardizedSelector.set(selectorRef, { path: selector.path, schema: SelectorTracker.getStandardSchema(selector.schema), }); const promiseKey = `${toKey(address)}?${selectorRef}`; this.selectorPromises.set(promiseKey, promise); } has(address: BaseMemoryAddress): boolean { return this.refTracker.has(toKey(address)); } hasSelector( address: BaseMemoryAddress, selector: SchemaPathSelector, ): boolean { const selectorRefs = this.refTracker.get(toKey(address)); if (selectorRefs !== undefined) { const selectorRef = selectorRefFor(selector); return selectorRefs.has(selectorRef); } return false; } getSupersetSelector( address: BaseMemoryAddress, selector: SchemaPathSelector, cfc: ContextualFlowControl, ): [SchemaPathSelector?, Promise?] { const selectorRefs = this.refTracker.get(toKey(address)); const noMatch: [SchemaPathSelector?, Promise?] = [undefined, undefined]; if (selectorRefs === undefined) { return noMatch; } const newSelectorRef = selectorRefFor(selector); if (selectorRefs.has(newSelectorRef)) { const promiseKey = `${toKey(address)}?${newSelectorRef}`; return [ this.standardizedSelector.get(newSelectorRef)!, this.selectorPromises.get(promiseKey)!, ]; } const newAddress = { ...address, path: selector.path }; const newSchema = selector.schema ? SelectorTracker.getStandardSchema(selector.schema) : false; const newSchemaHash = newSchema === false ? false : hashSchema(newSchema); const newSchemaObj = isRecord(newSchema) ? newSchema : undefined; // Constant across the candidate loop; hoisted so the $defs-insensitive // comparison below doesn't recompute it per tracked selector. let newSchemaRefCount: number | undefined; for (const selectorRef of selectorRefs) { const existingSelector = this.standardizedSelector.get(selectorRef)!; const existingAddress = { ...address, path: existingSelector.path }; if (Address.includes(existingAddress, newAddress)) { const existingSchema = existingSelector.schema; if (existingSchema === undefined) { continue; } const subPath = newAddress.path.slice(existingAddress.path.length); // Tracked schemas are interned (deep-frozen), so this derivation hits // cfc.schemaAtPath's identity-keyed memo. const subSchema = cfc.schemaAtPath( existingSchema, subPath, undefined, false, false, ); const sortedSubSchema = SelectorTracker.getStandardSchema(subSchema); const sortedSubSchemaHash = typeof sortedSubSchema === "boolean" ? sortedSubSchema : hashSchema(sortedSubSchema); if ( ContextualFlowControl.isTrueSchema(subSchema) || sortedSubSchemaHash === newSchemaHash || SelectorTracker.checkAnyOf(subSchema, newSchemaHash) || newSchema === false ) { const promiseKey = `${toKey(address)}?${selectorRef}`; return [existingSelector, this.selectorPromises.get(promiseKey)!]; } else { const sortedSubSchemaObj = isRecord(sortedSubSchema) ? sortedSubSchema : undefined; if (newSchemaObj && sortedSubSchemaObj) { if (newSchemaRefCount === undefined) { const newSchemaRefs = new Set(); ContextualFlowControl.findRefs(newSchema, newSchemaRefs); newSchemaRefCount = newSchemaRefs.size; } if ( newSchemaRefCount == 0 && noDefsStandardHash(sortedSubSchemaObj) === noDefsStandardHash(newSchemaObj) ) { const promiseKey = `${toKey(address)}?${selectorRef}`; return [existingSelector, this.selectorPromises.get(promiseKey)!]; } } } } } return noMatch; } get(address: BaseMemoryAddress): IteratorObject { const selectorRefs = this.refTracker.get(toKey(address)) ?? []; return selectorRefs.values().map((selectorRef) => this.selectors.get(selectorRef)! ); } getPromise( address: BaseMemoryAddress, selector: SchemaPathSelector, ): Promise | undefined { const selectorRef = selectorRefFor(selector); const promiseKey = `${toKey(address)}?${selectorRef}`; return this.selectorPromises.get(promiseKey); } delete(address: BaseMemoryAddress, selector: SchemaPathSelector): void { const selectorRef = selectorRefFor(selector); this.refTracker.deleteValue(toKey(address), selectorRef); const promiseKey = `${toKey(address)}?${selectorRef}`; this.selectorPromises.delete(promiseKey); if (![...this.refTracker].some(([, refs]) => refs.has(selectorRef))) { this.selectors.delete(selectorRef); this.standardizedSelector.delete(selectorRef); } } getAllPromises(): Iterable> { return this.selectorPromises.values(); } getAllSubscriptions(): { address: BaseMemoryAddress; selector: SchemaPathSelector; }[] { const subscriptions: { address: BaseMemoryAddress; selector: SchemaPathSelector; }[] = []; for (const [factKey, selectorRefs] of this.refTracker) { const address = fromKey(factKey); for (const selectorRef of selectorRefs) { const selector = this.selectors.get(selectorRef); if (selector) { subscriptions.push({ address, selector }); } } } return subscriptions; } static checkAnyOf( schema: JSONSchema, schemaHash: string | false, ): boolean { return isRecord(schema) && Array.isArray(schema.anyOf) && (schema.anyOf.some((item) => SelectorTracker.#anyOfItemHashes(schema, item).includes( schemaHash as string, ) )); } /** * The standardized hashes an anyOf item can match under: its plain form, * its `$defs`-grafted form, and its `$ref`-resolved form. Computing these * builds fresh schema objects and re-hashes them, so cache the resulting * hash strings per (parent schema, item) identity when the parent is * deep-frozen (its items then are too). */ static #anyOfItemHashesCache = new WeakMap< object, Map >(); static #anyOfItemHashes( schema: JSONSchema & object, item: JSONSchema, ): readonly string[] { const cacheable = isDeepFrozen(schema); let byItem: Map | undefined; if (cacheable) { byItem = SelectorTracker.#anyOfItemHashesCache.get(schema); const cached = byItem?.get(item); if (cached !== undefined) { return cached; } } const hashes: string[] = []; let current = SelectorTracker.getStandardSchema(item); hashes.push(hashSchema(current)); if (schema.$defs !== undefined) { current = SelectorTracker.getStandardSchema( schemaWithProperties(current, { $defs: schema.$defs }), ); hashes.push(hashSchema(current)); } if (isRecord(current) && current.$ref !== undefined) { hashes.push( hashSchema( SelectorTracker.getStandardSchema( ContextualFlowControl.resolveSchemaRefs( current, schema, ) as JSONSchema, ), ), ); } if (cacheable) { if (byItem === undefined) { byItem = new Map(); SelectorTracker.#anyOfItemHashesCache.set(schema, byItem); } byItem.set(item, hashes); } return hashes; } static getStandardSchema(schema: JSONSchema): JSONSchema { if (typeof schema === "boolean") { return schema; } const cacheable = isDeepFrozen(schema); if (cacheable) { const cached = standardizedSchemaCache.get(schema); if (cached !== undefined) { return cached; } } // Content-keyed lookup. For frozen schemas the hash itself is cached by // identity (value-hash WeakMap), so a fresh-but-equal frozen copy costs // one walk ever; mutable schemas re-hash per call, which is what keeps // in-place edits correct. const contentKey = hashSchema(schema); const byContent = standardizedByContentCache.get(contentKey); if (byContent !== undefined) { if (cacheable) { standardizedSchemaCache.set(schema, byContent); } return byContent; } const traverse = ( value: Readonly, ): FabricValue => { if (isRecord(value)) { if (Array.isArray(value)) { return value.map((val) => traverse(val)); } else { return Object.fromEntries( Object.entries(value).filter(([key, _val]) => key !== "asCell" && key !== "asStream" ).sort(([keyA, _valA], [keyB, _valB]) => keyA < keyB ? -1 : keyA > keyB ? 1 : 0 ).map(([key, val]: [PropertyKey, any]) => [ key.toString(), traverse(val), ]), ); } } else return value; }; const standardized = internSchema(traverse(schema) as JSONSchema); if (cacheable) { standardizedSchemaCache.set(schema, standardized); } standardizedByContentCache.put(contentKey, standardized); return standardized; } }