From 7b6ddd0d5541fbb1ae83c80de758edbd30493f28 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 23:06:29 -0700 Subject: [PATCH] Add typed plugin storage collections --- packages/core/sdk/src/executor.ts | 258 +++++++++++++++++++ packages/core/sdk/src/index.ts | 40 ++- packages/core/sdk/src/plugin-storage.test.ts | 255 ++++++++++++++++++ packages/core/sdk/src/plugin-storage.ts | 159 ++++++++++++ packages/core/sdk/src/plugin.ts | 8 +- packages/core/sdk/src/shared.ts | 20 ++ 6 files changed, 730 insertions(+), 10 deletions(-) create mode 100644 packages/core/sdk/src/plugin-storage.test.ts diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index c8fac961d..0dcd20563 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -120,8 +120,13 @@ import type { } from "./plugin"; import { pluginStorageId, + type PluginStorageCollectionData, + type PluginStorageCollectionDefinition, + type PluginStorageCollectionQueryInput, type PluginStorageEntry, type PluginStorageFacade, + type PluginStorageRuntimeCollectionDefinition, + type PluginStorageRuntimeIndexSpec, } from "./plugin-storage"; import type { Scope } from "./scope"; import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; @@ -801,6 +806,108 @@ const pluginStorageEntryFromRow = (row: CoreRow<"plugin_storage">): PluginSto updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at), }); +const pluginStorageIndexSpecFields = (spec: PluginStorageRuntimeIndexSpec): readonly string[] => + typeof spec === "string" ? [spec] : spec; + +const pluginStorageCollectionIndexedFields = ( + definition: PluginStorageRuntimeCollectionDefinition, +): ReadonlySet => + new Set(definition.indexes.flatMap((spec) => pluginStorageIndexSpecFields(spec))); + +const pluginStorageQueryValidationError = ( + definition: PluginStorageRuntimeCollectionDefinition, + query: PluginStorageCollectionQueryInput | undefined, +): StorageError | null => { + if (!query) return null; + + const indexedFields = pluginStorageCollectionIndexedFields(definition); + const fields = new Set([ + ...Object.keys(query.where ?? {}), + ...(query.orderBy ?? []).map((order) => order.field), + ]); + for (const field of fields) { + if (!indexedFields.has(field)) { + return new StorageError({ + message: `Plugin storage collection "${definition.name}" cannot query field "${field}" because it is not declared as an index`, + cause: undefined, + }); + } + } + + if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 0)) { + return new StorageError({ + message: `Plugin storage collection "${definition.name}" received an invalid query limit`, + cause: undefined, + }); + } + if (query.offset !== undefined && (!Number.isInteger(query.offset) || query.offset < 0)) { + return new StorageError({ + message: `Plugin storage collection "${definition.name}" received an invalid query offset`, + cause: undefined, + }); + } + + return null; +}; + +const isPluginStorageRecord = (value: unknown): value is Readonly> => + value !== null && typeof value === "object" && !Array.isArray(value); + +const pluginStorageWhereOperators = ["eq", "in", "gt", "gte", "lt", "lte"] as const; + +const isPluginStorageWhereFilter = (value: unknown): value is Readonly> => + isPluginStorageRecord(value) && pluginStorageWhereOperators.some((operator) => operator in value); + +const pluginStorageComparableValue = (value: unknown): string | number | boolean | null => { + if (value instanceof Date) return value.getTime(); + if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") { + return value; + } + if (value == null) return null; + return JSON.stringify(value); +}; + +const comparePluginStorageValues = (left: unknown, right: unknown): number => { + const leftValue = pluginStorageComparableValue(left); + const rightValue = pluginStorageComparableValue(right); + if (leftValue === rightValue) return 0; + if (leftValue === null) return -1; + if (rightValue === null) return 1; + if (typeof leftValue === "number" && typeof rightValue === "number") { + return leftValue - rightValue; + } + return String(leftValue).localeCompare(String(rightValue)); +}; + +const pluginStorageDataField = (data: unknown, field: string): unknown => + isPluginStorageRecord(data) ? data[field] : undefined; + +const matchesPluginStorageWhereValue = (actual: unknown, expected: unknown): boolean => { + if (!isPluginStorageWhereFilter(expected)) return Object.is(actual, expected); + + if ("eq" in expected && !Object.is(actual, expected.eq)) return false; + if ("in" in expected) { + const values = expected.in; + if (!Array.isArray(values) || !values.some((value) => Object.is(actual, value))) return false; + } + if ("gt" in expected && !(comparePluginStorageValues(actual, expected.gt) > 0)) return false; + if ("gte" in expected && !(comparePluginStorageValues(actual, expected.gte) >= 0)) return false; + if ("lt" in expected && !(comparePluginStorageValues(actual, expected.lt) < 0)) return false; + if ("lte" in expected && !(comparePluginStorageValues(actual, expected.lte) <= 0)) return false; + + return true; +}; + +const rowMatchesPluginStorageWhere = ( + row: CoreRow<"plugin_storage">, + where: Readonly> | undefined, +): boolean => { + if (!where) return true; + return Object.entries(where).every(([field, expected]) => + matchesPluginStorageWhereValue(pluginStorageDataField(row.data, field), expected), + ); +}; + const makePluginStorageFacade = (input: { readonly core: ReturnType; readonly pluginId: string; @@ -828,7 +935,158 @@ const makePluginStorageFacade = (input: { .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null)) .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))); + const queryCollection = ( + definition: TDefinition, + queryInput?: PluginStorageCollectionQueryInput, + ) => + Effect.gen(function* () { + const validationError = pluginStorageQueryValidationError( + definition, + queryInput as + | PluginStorageCollectionQueryInput + | undefined, + ); + if (validationError) return yield* validationError; + + const rows = yield* input.core.findMany("plugin_storage", { + where: whereFor(definition.name), + }); + const filtered = sortByScopePrecedence(rows) + .filter((row) => + queryInput?.keyPrefix === undefined ? true : row.key.startsWith(queryInput.keyPrefix), + ) + .filter((row) => + rowMatchesPluginStorageWhere( + row, + queryInput?.where as Readonly> | undefined, + ), + ); + + const sorted = + queryInput?.orderBy && queryInput.orderBy.length > 0 + ? [...filtered].sort((left, right) => { + for (const order of queryInput.orderBy ?? []) { + const direction = order.direction === "desc" ? -1 : 1; + const compared = + comparePluginStorageValues( + pluginStorageDataField(left.data, order.field), + pluginStorageDataField(right.data, order.field), + ) * direction; + if (compared !== 0) return compared; + } + return ( + input.scopeIds.indexOf(left.scope_id) - input.scopeIds.indexOf(right.scope_id) || + left.key.localeCompare(right.key) + ); + }) + : filtered; + + const offset = queryInput?.offset ?? 0; + const limited = + queryInput?.limit === undefined + ? sorted.slice(offset) + : sorted.slice(offset, offset + queryInput.limit); + return limited.map((row) => + pluginStorageEntryFromRow>(row), + ); + }); + return { + collection: (definition) => ({ + get: (storageInput) => + getVisible(definition.name, storageInput.key) as Effect.Effect< + PluginStorageEntry> | null, + StorageFailure + >, + getAtScope: (storageInput) => + input.core + .findFirst("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: definition.name, + key: storageInput.key, + }), + ), + }) + .pipe( + Effect.map((row) => + row + ? pluginStorageEntryFromRow>(row) + : null, + ), + ), + list: (storageInput) => + queryCollection(definition, { + keyPrefix: storageInput?.keyPrefix, + }), + put: (storageInput) => + Effect.gen(function* () { + if (!input.scopeIds.includes(storageInput.scope)) { + return yield* new StorageError({ + message: `Unknown plugin storage target scope: ${storageInput.scope}`, + cause: undefined, + }); + } + const row = yield* input.core.findFirst("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: definition.name, + key: storageInput.key, + }), + ), + }); + if (row) { + const now = new Date(); + yield* input.core.updateMany("plugin_storage", { + where: byScopedId(storageInput.scope, row.id), + set: { + data: storageInput.data, + updated_at: now, + }, + }); + return pluginStorageEntryFromRow({ + ...row, + data: storageInput.data, + updated_at: now, + }); + } + + const now = new Date(); + const created = yield* input.core.create("plugin_storage", { + id: pluginStorageId({ + pluginId: input.pluginId, + collection: definition.name, + key: storageInput.key, + }), + scope_id: storageInput.scope, + plugin_id: input.pluginId, + collection: definition.name, + key: storageInput.key, + data: storageInput.data, + created_at: now, + updated_at: now, + }); + return pluginStorageEntryFromRow(created); + }), + query: (queryInput) => queryCollection(definition, queryInput), + count: (queryInput) => + queryCollection(definition, queryInput).pipe(Effect.map((entries) => entries.length)), + remove: (storageInput) => + input.core.deleteMany("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: definition.name, + key: storageInput.key, + }), + ), + }), + }), get: (storageInput) => getVisible(storageInput.collection, storageInput.key), getAtScope: (storageInput) => input.core diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 007562b3e..179998a91 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -199,6 +199,37 @@ export { makeInMemoryBlobStore, } from "./blob"; +// Plugin storage +export { + definePluginStorageCollection, + pluginStorageId, + type PluginStorageCollectionDefinition, + type PluginStorageCollectionFacade, + type PluginStorageCollectionIndexedField, + type PluginStorageCollectionKeyInput, + type PluginStorageCollectionListInput, + type PluginStorageCollectionOrderBy, + type PluginStorageCollectionPutInput, + type PluginStorageCollectionQueryInput, + type PluginStorageCollectionScopedKeyInput, + type PluginStorageCollectionWhere, + type PluginStorageConfig, + type PluginStorageEntry, + type PluginStorageFacade, + type PluginStorageIndexField, + type PluginStorageIndexSpec, + type PluginStorageKeyInput, + type PluginStorageListInput, + type PluginStoragePutInput, + type PluginStorageRuntimeCollectionDefinition, + type PluginStorageRuntimeIndexSpec, + type PluginStorageSchema, + type PluginStorageSchemaType, + type PluginStorageScopedKeyInput, + type PluginStorageWhereFilter, + type PluginStorageWhereValue, +} from "./plugin-storage"; + // OAuth 2.1 export { type OAuthService, @@ -320,15 +351,6 @@ export { definePlugin, tool, } from "./plugin"; -export { - pluginStorageId, - type PluginStorageEntry, - type PluginStorageFacade, - type PluginStorageKeyInput, - type PluginStorageListInput, - type PluginStoragePutInput, - type PluginStorageScopedKeyInput, -} from "./plugin-storage"; // Executor export { diff --git a/packages/core/sdk/src/plugin-storage.test.ts b/packages/core/sdk/src/plugin-storage.test.ts new file mode 100644 index 000000000..3e59d1138 --- /dev/null +++ b/packages/core/sdk/src/plugin-storage.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Schema } from "effect"; + +import { createExecutor } from "./executor"; +import { StorageError } from "./fuma-runtime"; +import { ScopeId } from "./ids"; +import { definePlugin } from "./plugin"; +import { + definePluginStorageCollection, + type PluginStorageCollectionFacade, + type PluginStorageCollectionQueryInput, + type PluginStorageCollectionWhere, +} from "./plugin-storage"; +import { Scope } from "./scope"; +import { makeTestConfig, makeTestExecutor } from "./testing"; + +const ToolCall = Schema.Struct({ + runId: Schema.String, + toolId: Schema.String, + userId: Schema.NullOr(Schema.String), + clientName: Schema.NullOr(Schema.String), + status: Schema.Literals(["ok", "failed", "blocked"]), + startedAt: Schema.String, + durationMs: Schema.Number, +}); + +const toolCalls = definePluginStorageCollection("toolCalls", ToolCall, { + indexes: ["runId", "toolId", "status", "clientName", "startedAt", ["toolId", "startedAt"]], +}); + +type ToolCall = typeof ToolCall.Type; + +const assertPluginStorageTypes = (storage: PluginStorageCollectionFacade) => { + const validQuery = storage.query({ where: { toolId: "shell" } }); + + // @ts-expect-error durationMs is part of the data shape but is not declared as an index. + const invalidWhereQuery = storage.query({ where: { durationMs: 100 } }); + + // @ts-expect-error orderBy is also restricted to declared index fields. + const invalidOrderQuery = storage.query({ orderBy: [{ field: "durationMs" }] }); + + // @ts-expect-error indexes must point at fields in the collection schema. + definePluginStorageCollection("bad", ToolCall, { indexes: ["missing"] }); + + void validQuery; + void invalidWhereQuery; + void invalidOrderQuery; +}; +void assertPluginStorageTypes; + +const uncheckedToolCallWhere = ( + where: Readonly>, +): PluginStorageCollectionWhere => + where as PluginStorageCollectionWhere; + +const executionHistoryPlugin = definePlugin(() => ({ + id: "executionHistory" as const, + pluginStorage: { toolCalls }, + storage: ({ pluginStorage }) => ({ + toolCalls: pluginStorage.collection(toolCalls), + }), + extension: (ctx) => ({ + record: (scope: string, key: string, data: ToolCall) => + ctx.storage.toolCalls.put({ scope, key, data }), + get: (key: string) => ctx.storage.toolCalls.get({ key }), + query: (input?: PluginStorageCollectionQueryInput) => + ctx.storage.toolCalls.query(input), + count: ( + input?: Omit< + PluginStorageCollectionQueryInput, + "orderBy" | "limit" | "offset" + >, + ) => ctx.storage.toolCalls.count(input), + queryUnindexed: () => + ctx.storage.toolCalls.query({ + where: uncheckedToolCallWhere({ durationMs: 100 }), + }), + }), +}))(); + +const scope = (id: string, name: string) => + Scope.make({ + id: ScopeId.make(id), + name, + createdAt: new Date(), + }); + +const call = (input: { + readonly runId: string; + readonly toolId: string; + readonly status: ToolCall["status"]; + readonly startedAt: string; + readonly clientName?: string | null; + readonly userId?: string | null; + readonly durationMs?: number; +}): ToolCall => ({ + runId: input.runId, + toolId: input.toolId, + userId: input.userId ?? null, + clientName: input.clientName ?? null, + status: input.status, + startedAt: input.startedAt, + durationMs: input.durationMs ?? 0, +}); + +describe("plugin storage collections", () => { + it.effect("queries declared indexes through the executor's SQLite FumaDB target", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + backend: "sqlite", + plugins: [executionHistoryPlugin] as const, + }); + const targetScope = "test-scope"; + + yield* executor.executionHistory.record( + targetScope, + "call-1", + call({ + runId: "run-a", + toolId: "browser", + status: "failed", + clientName: "codex", + startedAt: "2026-05-29T10:00:00.000Z", + durationMs: 320, + }), + ); + yield* executor.executionHistory.record( + targetScope, + "call-2", + call({ + runId: "run-a", + toolId: "shell", + status: "ok", + clientName: "codex", + startedAt: "2026-05-29T10:01:00.000Z", + durationMs: 42, + }), + ); + yield* executor.executionHistory.record( + targetScope, + "call-3", + call({ + runId: "run-b", + toolId: "shell", + status: "failed", + clientName: "codex", + startedAt: "2026-05-29T10:02:00.000Z", + durationMs: 77, + }), + ); + + const failed = yield* executor.executionHistory.query({ + where: { + clientName: "codex", + status: "failed", + }, + orderBy: [{ field: "startedAt", direction: "desc" }], + limit: 10, + }); + expect(failed.map((entry) => entry.key)).toEqual(["call-3", "call-1"]); + expect(failed.map((entry) => entry.data.toolId)).toEqual(["shell", "browser"]); + + const shellCount = yield* executor.executionHistory.count({ + where: { toolId: "shell" }, + }); + expect(shellCount).toBe(2); + }), + ); + + it.effect("uses the executor scope stack while sharing one plugin_storage table", () => + Effect.gen(function* () { + const org = scope("org", "Org"); + const user = scope("user", "User"); + const plugins = [executionHistoryPlugin] as const; + const config = makeTestConfig({ backend: "sqlite", plugins, scopes: [org] }); + + const orgExecutor = yield* createExecutor({ + ...config, + scopes: [org], + plugins, + }); + yield* orgExecutor.executionHistory.record( + org.id, + "shared", + call({ + runId: "run-scope", + toolId: "shell", + status: "ok", + startedAt: "2026-05-29T11:00:00.000Z", + }), + ); + + const userOnlyExecutor = yield* createExecutor({ + ...config, + scopes: [user], + plugins, + }); + const userOnlyRows = yield* userOnlyExecutor.executionHistory.query({ + where: { runId: "run-scope" }, + }); + expect(userOnlyRows).toEqual([]); + + const stackedExecutor = yield* createExecutor({ + ...config, + scopes: [user, org], + plugins, + }); + yield* stackedExecutor.executionHistory.record( + user.id, + "shared", + call({ + runId: "run-scope", + toolId: "browser", + status: "failed", + startedAt: "2026-05-29T11:01:00.000Z", + }), + ); + + const visibleShared = yield* stackedExecutor.executionHistory.get("shared"); + expect(visibleShared?.scopeId).toBe(user.id); + expect(visibleShared?.data.toolId).toBe("browser"); + + const scopedRows = yield* stackedExecutor.executionHistory.query({ + where: { runId: "run-scope" }, + orderBy: [{ field: "startedAt" }], + }); + expect( + scopedRows.map((entry) => [entry.key, String(entry.scopeId), entry.data.toolId]), + ).toEqual([ + ["shared", org.id, "shell"], + ["shared", user.id, "browser"], + ]); + }), + ); + + it.effect("rejects runtime queries against undeclared index fields", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + backend: "sqlite", + plugins: [executionHistoryPlugin] as const, + }); + + const exit = yield* Effect.exit(executor.executionHistory.queryUnindexed()); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + + const reason = exit.cause.reasons.find(Cause.isFailReason); + expect(reason?.error).toBeInstanceOf(StorageError); + expect(reason?.error).toMatchObject({ + message: + 'Plugin storage collection "toolCalls" cannot query field "durationMs" because it is not declared as an index', + }); + }), + ); +}); diff --git a/packages/core/sdk/src/plugin-storage.ts b/packages/core/sdk/src/plugin-storage.ts index d9c4a3534..40208b7e3 100644 --- a/packages/core/sdk/src/plugin-storage.ts +++ b/packages/core/sdk/src/plugin-storage.ts @@ -3,6 +3,98 @@ import { Effect } from "effect"; import type { StorageFailure } from "./fuma-runtime"; import type { ScopeId } from "./ids"; +export type PluginStorageSchema = { + readonly Type: object; +}; + +export type PluginStorageSchemaType = TSchema["Type"]; + +export type PluginStorageIndexField = Extract; + +export type PluginStorageIndexSpec = + | PluginStorageIndexField + | readonly PluginStorageIndexField[]; + +export type PluginStorageRuntimeIndexSpec = string | readonly string[]; + +export interface PluginStorageRuntimeCollectionDefinition { + readonly name: string; + readonly schema: PluginStorageSchema; + readonly indexes: readonly PluginStorageRuntimeIndexSpec[]; +} + +export interface PluginStorageCollectionDefinition< + TName extends string = string, + TData extends object = Record, + TIndexes extends readonly PluginStorageIndexSpec[] = + readonly PluginStorageIndexSpec[], +> extends PluginStorageRuntimeCollectionDefinition { + readonly name: TName; + readonly schema: PluginStorageSchema; + readonly indexes: TIndexes; +} + +export type PluginStorageConfig = Readonly< + Record +>; + +export const definePluginStorageCollection = < + const TName extends string, + const TSchema extends PluginStorageSchema, + const TIndexes extends readonly PluginStorageIndexSpec>[] = + readonly [], +>( + name: TName, + schema: TSchema, + options?: { + readonly indexes?: TIndexes; + }, +): PluginStorageCollectionDefinition, TIndexes> => ({ + name, + schema, + indexes: (options?.indexes ?? []) as TIndexes, +}); + +export type PluginStorageCollectionData = + TDefinition extends PluginStorageCollectionDefinition + ? TData + : never; + +export type PluginStorageIndexFields = TIndexes extends readonly (infer TIndex)[] + ? TIndex extends readonly (infer TField)[] + ? Extract + : Extract + : never; + +export type PluginStorageCollectionIndexedField = + TDefinition extends PluginStorageCollectionDefinition + ? PluginStorageIndexFields + : never; + +export interface PluginStorageWhereFilter { + readonly eq?: TValue; + readonly in?: readonly TValue[]; + readonly gt?: TValue; + readonly gte?: TValue; + readonly lt?: TValue; + readonly lte?: TValue; +} + +export type PluginStorageWhereValue = TValue | PluginStorageWhereFilter; + +export type PluginStorageCollectionWhere = { + readonly [TField in PluginStorageCollectionIndexedField]?: PluginStorageWhereValue< + TField extends keyof PluginStorageCollectionData + ? PluginStorageCollectionData[TField] + : never + >; +}; + +export interface PluginStorageCollectionOrderBy { + readonly field: PluginStorageCollectionIndexedField; + readonly direction?: "asc" | "desc"; +} + export interface PluginStorageKeyInput { readonly collection: string; readonly key: string; @@ -21,6 +113,32 @@ export interface PluginStoragePutInput extends PluginStorageScopedKeyInput { readonly data: unknown; } +export interface PluginStorageCollectionKeyInput { + readonly key: string; +} + +export interface PluginStorageCollectionScopedKeyInput extends PluginStorageCollectionKeyInput { + readonly scope: ScopeId | string; +} + +export interface PluginStorageCollectionListInput { + readonly keyPrefix?: string; +} + +export interface PluginStorageCollectionPutInput< + TData extends object, +> extends PluginStorageCollectionScopedKeyInput { + readonly data: TData; +} + +export interface PluginStorageCollectionQueryInput { + readonly keyPrefix?: string; + readonly where?: PluginStorageCollectionWhere; + readonly orderBy?: readonly PluginStorageCollectionOrderBy[]; + readonly limit?: number; + readonly offset?: number; +} + export interface PluginStorageEntry { readonly id: string; readonly scopeId: ScopeId | string; @@ -32,7 +150,48 @@ export interface PluginStorageEntry { readonly updatedAt: Date; } +export interface PluginStorageCollectionFacade< + TDefinition extends PluginStorageCollectionDefinition = PluginStorageCollectionDefinition, +> { + readonly get: ( + input: PluginStorageCollectionKeyInput, + ) => Effect.Effect< + PluginStorageEntry> | null, + StorageFailure + >; + readonly getAtScope: ( + input: PluginStorageCollectionScopedKeyInput, + ) => Effect.Effect< + PluginStorageEntry> | null, + StorageFailure + >; + readonly list: ( + input?: PluginStorageCollectionListInput, + ) => Effect.Effect< + readonly PluginStorageEntry>[], + StorageFailure + >; + readonly put: ( + input: PluginStorageCollectionPutInput>, + ) => Effect.Effect>, StorageFailure>; + readonly query: ( + input?: PluginStorageCollectionQueryInput, + ) => Effect.Effect< + readonly PluginStorageEntry>[], + StorageFailure + >; + readonly count: ( + input?: Omit, "orderBy" | "limit" | "offset">, + ) => Effect.Effect; + readonly remove: ( + input: PluginStorageCollectionScopedKeyInput, + ) => Effect.Effect; +} + export interface PluginStorageFacade { + readonly collection: ( + definition: TDefinition, + ) => PluginStorageCollectionFacade; readonly get: ( input: PluginStorageKeyInput, ) => Effect.Effect | null, StorageFailure>; diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index ce5194397..e32213ae0 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -42,7 +42,7 @@ import type { SourceRemovalNotAllowedError, } from "./errors"; import type { OAuthService } from "./oauth"; -import type { PluginStorageFacade } from "./plugin-storage"; +import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; import type { CreateToolPolicyInput, RemoveToolPolicyInput, @@ -497,6 +497,12 @@ export interface PluginSpec< * across plugins are structurally impossible. */ readonly storage: (deps: StorageDeps) => TStore; + /** Host-owned plugin storage declarations. Plugins declare logical + * collections and indexed JSON fields here; data still lives in the + * executor's shared `plugin_storage` table instead of per-plugin + * adapter schemas. */ + readonly pluginStorage?: PluginStorageConfig; + /** JSON-serializable config the plugin wants its `./client` bundle to * see. The Vite plugin reads this off each `executor.config.ts` spec * at build time and bakes it into the virtual `plugins-client` diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 536c23e68..ccecebcae 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -57,13 +57,33 @@ export { } from "./credential-bindings"; export { + definePluginStorageCollection, pluginStorageId, + type PluginStorageCollectionDefinition, + type PluginStorageCollectionFacade, + type PluginStorageCollectionIndexedField, + type PluginStorageCollectionKeyInput, + type PluginStorageCollectionListInput, + type PluginStorageCollectionOrderBy, + type PluginStorageCollectionPutInput, + type PluginStorageCollectionQueryInput, + type PluginStorageCollectionScopedKeyInput, + type PluginStorageCollectionWhere, + type PluginStorageConfig, type PluginStorageEntry, type PluginStorageFacade, + type PluginStorageIndexField, + type PluginStorageIndexSpec, type PluginStorageKeyInput, type PluginStorageListInput, type PluginStoragePutInput, + type PluginStorageRuntimeCollectionDefinition, + type PluginStorageRuntimeIndexSpec, + type PluginStorageSchema, + type PluginStorageSchemaType, type PluginStorageScopedKeyInput, + type PluginStorageWhereFilter, + type PluginStorageWhereValue, } from "./plugin-storage"; export { SourceDetectionResult, type Source } from "./types";