import { closePluginStateSqliteStore, MAX_PLUGIN_STATE_VALUE_BYTES, pluginStateClear, pluginStateConsume, pluginStateDelete, pluginStateEntries, pluginStateLookup, pluginStateRegister, pluginStateRegisterIfAbsent, } from "./plugin-state-store.sqlite.js"; import type { OpenKeyedStoreOptions, PluginStateEntry, PluginStateKeyedStore, PluginStateStoreOperation, } from "./plugin-state-store.types.js"; import { PluginStateStoreError } from "./plugin-state-store.types.js"; export type { OpenKeyedStoreOptions, PluginStateEntry, PluginStateKeyedStore, PluginStateStoreErrorCode, PluginStateStoreOperation, PluginStateStoreProbeResult, PluginStateStoreProbeStep, } from "./plugin-state-store.types.js"; export { PluginStateStoreError } from "./plugin-state-store.types.js"; export { closePluginStateSqliteStore, isPluginStateDatabaseOpen, probePluginStateStore, sweepExpiredPluginStateEntries, } from "./plugin-state-store.sqlite.js"; const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/iu; const MAX_NAMESPACE_BYTES = 128; const MAX_KEY_BYTES = 512; const MAX_JSON_DEPTH = 64; type StoreOptionSignature = { maxEntries: number; defaultTtlMs?: number; }; const namespaceOptionSignatures = new Map(); const textEncoder = new TextEncoder(); function invalidInput( message: string, operation: PluginStateStoreOperation = "register", ): PluginStateStoreError { return new PluginStateStoreError(message, { code: "PLUGIN_STATE_INVALID_INPUT", operation, }); } function assertMaxBytes( label: string, value: string, max: number, operation: PluginStateStoreOperation = "register", ): void { if (textEncoder.encode(value).byteLength > max) { throw invalidInput(`plugin state ${label} must be <= ${max} bytes`, operation); } } function validateNamespace(value: string, operation: PluginStateStoreOperation = "open"): string { const trimmed = value.trim(); if (!NAMESPACE_PATTERN.test(trimmed)) { throw invalidInput(`plugin state namespace must be a safe path segment: ${value}`, operation); } assertMaxBytes("namespace", trimmed, MAX_NAMESPACE_BYTES, operation); return trimmed; } function validateKey(value: string, operation: PluginStateStoreOperation = "register"): string { const trimmed = value.trim(); if (!trimmed) { throw invalidInput("plugin state entry key must not be empty", operation); } assertMaxBytes("entry key", trimmed, MAX_KEY_BYTES, operation); return trimmed; } function validateMaxEntries(value: number): number { if (!Number.isInteger(value) || value < 1) { throw invalidInput("plugin state maxEntries must be an integer >= 1", "open"); } return value; } function validateOptionalTtlMs( value: number | undefined, operation: PluginStateStoreOperation = "register", ): number | undefined { if (value == null) { return undefined; } if (!Number.isInteger(value) || value < 1) { throw invalidInput("plugin state ttlMs must be a positive integer", operation); } return value; } function assertPlainJsonValue( value: unknown, seen: WeakSet, path: string, depth = 0, ): void { if (depth > MAX_JSON_DEPTH) { throw new PluginStateStoreError( `plugin state value nesting exceeds maximum depth of ${MAX_JSON_DEPTH}`, { code: "PLUGIN_STATE_LIMIT_EXCEEDED", operation: "register" }, ); } if (value === null) { return; } const valueType = typeof value; if (valueType === "string" || valueType === "boolean") { return; } if (valueType === "number") { if (!Number.isFinite(value)) { throw invalidInput(`plugin state value at ${path} must be a finite number`); } return; } if (valueType !== "object") { throw invalidInput(`plugin state value at ${path} must be JSON-serializable`); } const objectValue = value as object; if (seen.has(objectValue)) { throw invalidInput(`plugin state value at ${path} must not contain circular references`); } seen.add(objectValue); try { if (Array.isArray(value)) { for (let index = 0; index < value.length; index += 1) { if (!(index in value)) { throw invalidInput(`plugin state array at ${path} must not be sparse`); } assertPlainJsonValue(value[index], seen, `${path}[${index}]`, depth + 1); } return; } if (Object.getPrototypeOf(objectValue) !== Object.prototype) { throw invalidInput(`plugin state object at ${path} must be a plain object`); } const descriptorEntries = Object.entries(Object.getOwnPropertyDescriptors(objectValue)); const enumerableKeys = Object.keys(objectValue); if (Object.getOwnPropertySymbols(objectValue).length > 0) { throw invalidInput(`plugin state object at ${path} must not use symbol keys`); } if (descriptorEntries.length !== enumerableKeys.length) { throw invalidInput(`plugin state object at ${path} must not use non-enumerable properties`); } for (const [key, descriptor] of descriptorEntries) { if (descriptor.get || descriptor.set || !("value" in descriptor)) { throw invalidInput(`plugin state object at ${path}.${key} must use data properties`); } assertPlainJsonValue(descriptor.value, seen, `${path}.${key}`, depth + 1); } } finally { seen.delete(objectValue); } } function assertJsonSerializable(value: unknown): void { assertPlainJsonValue(value, new WeakSet(), "value"); } function assertValueSize(json: string): void { if (textEncoder.encode(json).byteLength > MAX_PLUGIN_STATE_VALUE_BYTES) { throw new PluginStateStoreError("plugin state value exceeds 64KB limit", { code: "PLUGIN_STATE_LIMIT_EXCEEDED", operation: "register", }); } } function assertConsistentOptions( pluginId: string, namespace: string, signature: StoreOptionSignature, ): void { const key = `${pluginId}\0${namespace}`; const existing = namespaceOptionSignatures.get(key); if (!existing) { namespaceOptionSignatures.set(key, signature); return; } if ( existing.maxEntries !== signature.maxEntries || existing.defaultTtlMs !== signature.defaultTtlMs ) { throw invalidInput( `plugin state namespace ${namespace} for ${pluginId} was reopened with incompatible options`, "open", ); } } function createKeyedStoreForPluginId( pluginId: string, options: OpenKeyedStoreOptions, ): PluginStateKeyedStore { const namespace = validateNamespace(options.namespace); const maxEntries = validateMaxEntries(options.maxEntries); const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs); assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs }); const prepareRegisterParams = ( key: string, value: T, opts?: { ttlMs?: number }, ): { key: string; valueJson: string; ttlMs?: number } => { const normalizedKey = validateKey(key, "register"); assertJsonSerializable(value); const json = JSON.stringify(value); assertValueSize(json); const ttlMs = validateOptionalTtlMs(opts?.ttlMs, "register") ?? defaultTtlMs; return { key: normalizedKey, valueJson: json, ...(ttlMs != null ? { ttlMs } : {}), }; }; return { async register(key, value, opts) { const params = prepareRegisterParams(key, value, opts); pluginStateRegister({ pluginId, namespace, key: params.key, valueJson: params.valueJson, maxEntries, ...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}), }); }, async registerIfAbsent(key, value, opts) { const params = prepareRegisterParams(key, value, opts); return pluginStateRegisterIfAbsent({ pluginId, namespace, key: params.key, valueJson: params.valueJson, maxEntries, ...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}), }); }, async lookup(key) { const normalizedKey = validateKey(key, "lookup"); return pluginStateLookup({ pluginId, namespace, key: normalizedKey }) as T | undefined; }, async consume(key) { const normalizedKey = validateKey(key, "consume"); return pluginStateConsume({ pluginId, namespace, key: normalizedKey }) as T | undefined; }, async delete(key) { const normalizedKey = validateKey(key, "delete"); return pluginStateDelete({ pluginId, namespace, key: normalizedKey }); }, async entries() { return pluginStateEntries({ pluginId, namespace }) as PluginStateEntry[]; }, async clear() { pluginStateClear({ pluginId, namespace }); }, }; } export function createPluginStateKeyedStore( pluginId: string, options: OpenKeyedStoreOptions, ): PluginStateKeyedStore { if (pluginId.startsWith("core:")) { throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open"); } return createKeyedStoreForPluginId(pluginId, options); } export function createCorePluginStateKeyedStore( options: OpenKeyedStoreOptions & { ownerId: `core:${string}` }, ): PluginStateKeyedStore { return createKeyedStoreForPluginId(options.ownerId, options); } export function resetPluginStateStoreForTests(): void { closePluginStateSqliteStore(); namespaceOptionSignatures.clear(); }