import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { plugins, pluginState } from "@paperclipai/db"; import type { PluginStateScopeKind, SetPluginState, ListPluginState, } from "@paperclipai/shared"; import { notFound } from "../errors.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Default namespace used when the plugin does not specify one. */ const DEFAULT_NAMESPACE = "default"; /** * Build the WHERE clause conditions for a scoped state lookup. * * The five-part composite key is: * `(pluginId, scopeKind, scopeId, namespace, stateKey)` * * `scopeId` may be null (for `instance` scope) or a non-empty string. */ function scopeConditions( pluginId: string, scopeKind: PluginStateScopeKind, scopeId: string | undefined | null, namespace: string, stateKey: string, ) { const conditions = [ eq(pluginState.pluginId, pluginId), eq(pluginState.scopeKind, scopeKind), eq(pluginState.namespace, namespace), eq(pluginState.stateKey, stateKey), ]; if (scopeId != null && scopeId !== "") { conditions.push(eq(pluginState.scopeId, scopeId)); } else { conditions.push(isNull(pluginState.scopeId)); } return and(...conditions); } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- /** * Plugin State Store — scoped key-value persistence for plugin workers. * * Provides `get`, `set`, `delete`, and `list` operations over the * `plugin_state` table. Each plugin's data is strictly namespaced by * `pluginId` so plugins cannot read or write each other's state. * * This service implements the server-side backing for the `ctx.state` SDK * client exposed to plugin workers. The host is responsible for: * - enforcing `plugin.state.read` capability before calling `get` / `list` * - enforcing `plugin.state.write` capability before calling `set` / `delete` * * @see PLUGIN_SPEC.md §14 — SDK Surface (`ctx.state`) * @see PLUGIN_SPEC.md §15.1 — Capabilities: Plugin State * @see PLUGIN_SPEC.md §21.3 — `plugin_state` table */ export function pluginStateStore(db: Db) { // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- async function assertPluginExists(pluginId: string): Promise { const rows = await db .select({ id: plugins.id }) .from(plugins) .where(eq(plugins.id, pluginId)); if (rows.length === 0) { throw notFound(`Plugin not found: ${pluginId}`); } } // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- return { /** * Read a state value. * * Returns the stored JSON value, or `null` if no entry exists for the * given scope and key. * * Requires `plugin.state.read` capability (enforced by the caller). * * @param pluginId - UUID of the owning plugin * @param scopeKind - Granularity of the scope * @param scopeId - Identifier for the scoped entity (null for `instance` scope) * @param stateKey - The key to read * @param namespace - Sub-namespace (defaults to `"default"`) */ get: async ( pluginId: string, scopeKind: PluginStateScopeKind, stateKey: string, { scopeId, namespace = DEFAULT_NAMESPACE, }: { scopeId?: string; namespace?: string } = {}, ): Promise => { const rows = await db .select() .from(pluginState) .where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey)); return rows[0]?.valueJson ?? null; }, /** * Write (create or replace) a state value. * * Uses an upsert so the caller does not need to check for prior existence. * On conflict (same composite key) the existing row's `value_json` and * `updated_at` are overwritten. * * Requires `plugin.state.write` capability (enforced by the caller). * * @param pluginId - UUID of the owning plugin * @param input - Scope key and value to store */ set: async (pluginId: string, input: SetPluginState): Promise => { await assertPluginExists(pluginId); const namespace = input.namespace ?? DEFAULT_NAMESPACE; const scopeId = input.scopeId ?? null; await db .insert(pluginState) .values({ pluginId, scopeKind: input.scopeKind, scopeId, namespace, stateKey: input.stateKey, valueJson: input.value, updatedAt: new Date(), }) .onConflictDoUpdate({ target: [ pluginState.pluginId, pluginState.scopeKind, pluginState.scopeId, pluginState.namespace, pluginState.stateKey, ], set: { valueJson: input.value, updatedAt: new Date(), }, }); }, /** * Delete a state value. * * No-ops silently if the entry does not exist (idempotent by design). * * Requires `plugin.state.write` capability (enforced by the caller). * * @param pluginId - UUID of the owning plugin * @param scopeKind - Granularity of the scope * @param stateKey - The key to delete * @param scopeId - Identifier for the scoped entity (null for `instance` scope) * @param namespace - Sub-namespace (defaults to `"default"`) */ delete: async ( pluginId: string, scopeKind: PluginStateScopeKind, stateKey: string, { scopeId, namespace = DEFAULT_NAMESPACE, }: { scopeId?: string; namespace?: string } = {}, ): Promise => { await db .delete(pluginState) .where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey)); }, /** * List all state entries for a plugin, optionally filtered by scope. * * Returns all matching rows as `PluginStateRecord`-shaped objects. * The `valueJson` field contains the stored value. * * Requires `plugin.state.read` capability (enforced by the caller). * * @param pluginId - UUID of the owning plugin * @param filter - Optional scope filters (scopeKind, scopeId, namespace) */ list: async (pluginId: string, filter: ListPluginState = {}): Promise => { const conditions = [eq(pluginState.pluginId, pluginId)]; if (filter.scopeKind !== undefined) { conditions.push(eq(pluginState.scopeKind, filter.scopeKind)); } if (filter.scopeId !== undefined) { conditions.push(eq(pluginState.scopeId, filter.scopeId)); } if (filter.namespace !== undefined) { conditions.push(eq(pluginState.namespace, filter.namespace)); } return db .select() .from(pluginState) .where(and(...conditions)); }, /** * Delete all state entries owned by a plugin. * * Called during plugin uninstall when `removeData = true`. Also useful * for resetting a plugin's state during testing. * * @param pluginId - UUID of the owning plugin */ deleteAll: async (pluginId: string): Promise => { await db .delete(pluginState) .where(eq(pluginState.pluginId, pluginId)); }, }; } export type PluginStateStore = ReturnType;