238 lines
7.6 KiB
TypeScript
238 lines
7.6 KiB
TypeScript
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<void> {
|
|
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<unknown> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<typeof pluginState.$inferSelect[]> => {
|
|
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<void> => {
|
|
await db
|
|
.delete(pluginState)
|
|
.where(eq(pluginState.pluginId, pluginId));
|
|
},
|
|
};
|
|
}
|
|
|
|
export type PluginStateStore = ReturnType<typeof pluginStateStore>;
|