Spaces:
Paused
Paused
File size: 7,780 Bytes
cf9339a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | 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>;
|