History

Add typed plugin storage collections (#870)

Rhys Sullivan committed 5 months ago

Commit be73c151 parent 1e915336 files · +730 −10Browse files

Unified · Split

6 files
  1. packages/core/sdk/src/executor.ts +258 −0
  2. packages/core/sdk/src/index.ts +31 −9
  3. packages/core/sdk/src/plugin-storage.test.ts +255 −0
  4. packages/core/sdk/src/plugin-storage.ts +159 −0
  5. packages/core/sdk/src/plugin.ts +7 −1
  6. packages/core/sdk/src/shared.ts +20 −0
packages/core/sdk/src/executor.ts+258−0
@@ -120,8 +120,13 @@ import type { · 119 unmodified lines
120120 } from "./plugin";
121121 import {
122122 pluginStorageId,
123+ type PluginStorageCollectionData,
124+ type PluginStorageCollectionDefinition,
125+ type PluginStorageCollectionQueryInput,
123126 type PluginStorageEntry,
124127 type PluginStorageFacade,
128+ type PluginStorageRuntimeCollectionDefinition,
129+ type PluginStorageRuntimeIndexSpec,
125130 } from "./plugin-storage";
126131 import type { Scope } from "./scope";
127132 import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets";
@@ -801,6 +806,108 @@ const pluginStorageEntryFromRow = <T>(row: CoreRow<"plugin_storage">): PluginSto · 673 unmodified lines
801806 updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at),
802807 });
803808
809+const pluginStorageIndexSpecFields = (spec: PluginStorageRuntimeIndexSpec): readonly string[] =>
810+ typeof spec === "string" ? [spec] : spec;
811+
812+const pluginStorageCollectionIndexedFields = (
813+ definition: PluginStorageRuntimeCollectionDefinition,
814+): ReadonlySet<string> =>
815+ new Set(definition.indexes.flatMap((spec) => pluginStorageIndexSpecFields(spec)));
816+
817+const pluginStorageQueryValidationError = (
818+ definition: PluginStorageRuntimeCollectionDefinition,
819+ query: PluginStorageCollectionQueryInput<PluginStorageCollectionDefinition> | undefined,
820+): StorageError | null => {
821+ if (!query) return null;
822+
823+ const indexedFields = pluginStorageCollectionIndexedFields(definition);
824+ const fields = new Set<string>([
825+ ...Object.keys(query.where ?? {}),
826+ ...(query.orderBy ?? []).map((order) => order.field),
827+ ]);
828+ for (const field of fields) {
829+ if (!indexedFields.has(field)) {
830+ return new StorageError({
831+ message: `Plugin storage collection "${definition.name}" cannot query field "${field}" because it is not declared as an index`,
832+ cause: undefined,
833+ });
834+ }
835+ }
836+
837+ if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 0)) {
838+ return new StorageError({
839+ message: `Plugin storage collection "${definition.name}" received an invalid query limit`,
840+ cause: undefined,
841+ });
842+ }
843+ if (query.offset !== undefined && (!Number.isInteger(query.offset) || query.offset < 0)) {
844+ return new StorageError({
845+ message: `Plugin storage collection "${definition.name}" received an invalid query offset`,
846+ cause: undefined,
847+ });
848+ }
849+
850+ return null;
851+};
852+
853+const isPluginStorageRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
854+ value !== null && typeof value === "object" && !Array.isArray(value);
855+
856+const pluginStorageWhereOperators = ["eq", "in", "gt", "gte", "lt", "lte"] as const;
857+
858+const isPluginStorageWhereFilter = (value: unknown): value is Readonly<Record<string, unknown>> =>
859+ isPluginStorageRecord(value) && pluginStorageWhereOperators.some((operator) => operator in value);
860+
861+const pluginStorageComparableValue = (value: unknown): string | number | boolean | null => {
862+ if (value instanceof Date) return value.getTime();
863+ if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") {
864+ return value;
865+ }
866+ if (value == null) return null;
867+ return JSON.stringify(value);
868+};
869+
870+const comparePluginStorageValues = (left: unknown, right: unknown): number => {
871+ const leftValue = pluginStorageComparableValue(left);
872+ const rightValue = pluginStorageComparableValue(right);
873+ if (leftValue === rightValue) return 0;
874+ if (leftValue === null) return -1;
875+ if (rightValue === null) return 1;
876+ if (typeof leftValue === "number" && typeof rightValue === "number") {
877+ return leftValue - rightValue;
878+ }
879+ return String(leftValue).localeCompare(String(rightValue));
880+};
881+
882+const pluginStorageDataField = (data: unknown, field: string): unknown =>
883+ isPluginStorageRecord(data) ? data[field] : undefined;
884+
885+const matchesPluginStorageWhereValue = (actual: unknown, expected: unknown): boolean => {
886+ if (!isPluginStorageWhereFilter(expected)) return Object.is(actual, expected);
887+
888+ if ("eq" in expected && !Object.is(actual, expected.eq)) return false;
889+ if ("in" in expected) {
890+ const values = expected.in;
891+ if (!Array.isArray(values) || !values.some((value) => Object.is(actual, value))) return false;
892+ }
893+ if ("gt" in expected && !(comparePluginStorageValues(actual, expected.gt) > 0)) return false;
894+ if ("gte" in expected && !(comparePluginStorageValues(actual, expected.gte) >= 0)) return false;
895+ if ("lt" in expected && !(comparePluginStorageValues(actual, expected.lt) < 0)) return false;
896+ if ("lte" in expected && !(comparePluginStorageValues(actual, expected.lte) <= 0)) return false;
897+
898+ return true;
899+};
900+
901+const rowMatchesPluginStorageWhere = (
902+ row: CoreRow<"plugin_storage">,
903+ where: Readonly<Record<string, unknown>> | undefined,
904+): boolean => {
905+ if (!where) return true;
906+ return Object.entries(where).every(([field, expected]) =>
907+ matchesPluginStorageWhereValue(pluginStorageDataField(row.data, field), expected),
908+ );
909+};
910+
804911 const makePluginStorageFacade = (input: {
805912 readonly core: ReturnType<typeof makeCoreDb>;
806913 readonly pluginId: string;
@@ -828,7 +935,158 @@ const makePluginStorageFacade = (input: { · 21 unmodified lines
828935 .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null))
829936 .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)));
830937
938+ const queryCollection = <TDefinition extends PluginStorageCollectionDefinition>(
939+ definition: TDefinition,
940+ queryInput?: PluginStorageCollectionQueryInput<TDefinition>,
941+ ) =>
942+ Effect.gen(function* () {
943+ const validationError = pluginStorageQueryValidationError(
944+ definition,
945+ queryInput as
946+ | PluginStorageCollectionQueryInput<PluginStorageCollectionDefinition>
947+ | undefined,
948+ );
949+ if (validationError) return yield* validationError;
950+
951+ const rows = yield* input.core.findMany("plugin_storage", {
952+ where: whereFor(definition.name),
953+ });
954+ const filtered = sortByScopePrecedence(rows)
955+ .filter((row) =>
956+ queryInput?.keyPrefix === undefined ? true : row.key.startsWith(queryInput.keyPrefix),
957+ )
958+ .filter((row) =>
959+ rowMatchesPluginStorageWhere(
960+ row,
961+ queryInput?.where as Readonly<Record<string, unknown>> | undefined,
962+ ),
963+ );
964+
965+ const sorted =
966+ queryInput?.orderBy && queryInput.orderBy.length > 0
967+ ? [...filtered].sort((left, right) => {
968+ for (const order of queryInput.orderBy ?? []) {
969+ const direction = order.direction === "desc" ? -1 : 1;
970+ const compared =
971+ comparePluginStorageValues(
972+ pluginStorageDataField(left.data, order.field),
973+ pluginStorageDataField(right.data, order.field),
974+ ) * direction;
975+ if (compared !== 0) return compared;
976+ }
977+ return (
978+ input.scopeIds.indexOf(left.scope_id) - input.scopeIds.indexOf(right.scope_id) ||
979+ left.key.localeCompare(right.key)
980+ );
981+ })
982+ : filtered;
983+
984+ const offset = queryInput?.offset ?? 0;
985+ const limited =
986+ queryInput?.limit === undefined
987+ ? sorted.slice(offset)
988+ : sorted.slice(offset, offset + queryInput.limit);
989+ return limited.map((row) =>
990+ pluginStorageEntryFromRow<PluginStorageCollectionData<TDefinition>>(row),
991+ );
992+ });
993+
831994 return {
995+ collection: (definition) => ({
996+ get: (storageInput) =>
997+ getVisible(definition.name, storageInput.key) as Effect.Effect<
998+ PluginStorageEntry<PluginStorageCollectionData<typeof definition>> | null,
999+ StorageFailure
1000+ >,
1001+ getAtScope: (storageInput) =>
1002+ input.core
1003+ .findFirst("plugin_storage", {
1004+ where: byScopedId(
1005+ storageInput.scope,
1006+ pluginStorageId({
1007+ pluginId: input.pluginId,
1008+ collection: definition.name,
1009+ key: storageInput.key,
1010+ }),
1011+ ),
1012+ })
1013+ .pipe(
1014+ Effect.map((row) =>
1015+ row
1016+ ? pluginStorageEntryFromRow<PluginStorageCollectionData<typeof definition>>(row)
1017+ : null,
1018+ ),
1019+ ),
1020+ list: (storageInput) =>
1021+ queryCollection(definition, {
1022+ keyPrefix: storageInput?.keyPrefix,
1023+ }),
1024+ put: (storageInput) =>
1025+ Effect.gen(function* () {
1026+ if (!input.scopeIds.includes(storageInput.scope)) {
1027+ return yield* new StorageError({
1028+ message: `Unknown plugin storage target scope: ${storageInput.scope}`,
1029+ cause: undefined,
1030+ });
1031+ }
1032+ const row = yield* input.core.findFirst("plugin_storage", {
1033+ where: byScopedId(
1034+ storageInput.scope,
1035+ pluginStorageId({
1036+ pluginId: input.pluginId,
1037+ collection: definition.name,
1038+ key: storageInput.key,
1039+ }),
1040+ ),
1041+ });
1042+ if (row) {
1043+ const now = new Date();
1044+ yield* input.core.updateMany("plugin_storage", {
1045+ where: byScopedId(storageInput.scope, row.id),
1046+ set: {
1047+ data: storageInput.data,
1048+ updated_at: now,
1049+ },
1050+ });
1051+ return pluginStorageEntryFromRow({
1052+ ...row,
1053+ data: storageInput.data,
1054+ updated_at: now,
1055+ });
1056+ }
1057+
1058+ const now = new Date();
1059+ const created = yield* input.core.create("plugin_storage", {
1060+ id: pluginStorageId({
1061+ pluginId: input.pluginId,
1062+ collection: definition.name,
1063+ key: storageInput.key,
1064+ }),
1065+ scope_id: storageInput.scope,
1066+ plugin_id: input.pluginId,
1067+ collection: definition.name,
1068+ key: storageInput.key,
1069+ data: storageInput.data,
1070+ created_at: now,
1071+ updated_at: now,
1072+ });
1073+ return pluginStorageEntryFromRow(created);
1074+ }),
1075+ query: (queryInput) => queryCollection(definition, queryInput),
1076+ count: (queryInput) =>
1077+ queryCollection(definition, queryInput).pipe(Effect.map((entries) => entries.length)),
1078+ remove: (storageInput) =>
1079+ input.core.deleteMany("plugin_storage", {
1080+ where: byScopedId(
1081+ storageInput.scope,
1082+ pluginStorageId({
1083+ pluginId: input.pluginId,
1084+ collection: definition.name,
1085+ key: storageInput.key,
1086+ }),
1087+ ),
1088+ }),
1089+ }),
8321090 get: (storageInput) => getVisible(storageInput.collection, storageInput.key),
8331091 getAtScope: (storageInput) =>
8341092 input.core
packages/core/sdk/src/index.ts+31−9
@@ -199,6 +199,37 @@ export { · 198 unmodified lines
199199 makeInMemoryBlobStore,
200200 } from "./blob";
201201
202+// Plugin storage
203+export {
204+ definePluginStorageCollection,
205+ pluginStorageId,
206+ type PluginStorageCollectionDefinition,
207+ type PluginStorageCollectionFacade,
208+ type PluginStorageCollectionIndexedField,
209+ type PluginStorageCollectionKeyInput,
210+ type PluginStorageCollectionListInput,
211+ type PluginStorageCollectionOrderBy,
212+ type PluginStorageCollectionPutInput,
213+ type PluginStorageCollectionQueryInput,
214+ type PluginStorageCollectionScopedKeyInput,
215+ type PluginStorageCollectionWhere,
216+ type PluginStorageConfig,
217+ type PluginStorageEntry,
218+ type PluginStorageFacade,
219+ type PluginStorageIndexField,
220+ type PluginStorageIndexSpec,
221+ type PluginStorageKeyInput,
222+ type PluginStorageListInput,
223+ type PluginStoragePutInput,
224+ type PluginStorageRuntimeCollectionDefinition,
225+ type PluginStorageRuntimeIndexSpec,
226+ type PluginStorageSchema,
227+ type PluginStorageSchemaType,
228+ type PluginStorageScopedKeyInput,
229+ type PluginStorageWhereFilter,
230+ type PluginStorageWhereValue,
231+} from "./plugin-storage";
232+
202233 // OAuth 2.1
203234 export {
204235 type OAuthService,
@@ -320,15 +351,6 @@ export { · 115 unmodified lines
320351 definePlugin,
321352 tool,
322353 } from "./plugin";
323−export {
324− pluginStorageId,
325− type PluginStorageEntry,
326− type PluginStorageFacade,
327− type PluginStorageKeyInput,
328− type PluginStorageListInput,
329− type PluginStoragePutInput,
330− type PluginStorageScopedKeyInput,
331−} from "./plugin-storage";
332354
333355 // Executor
334356 export {
packages/core/sdk/src/plugin-storage.test.ts+255−0
@@ -0,0 +1,255 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Cause, Effect, Exit, Schema } from "effect";
3+
4+import { createExecutor } from "./executor";
5+import { StorageError } from "./fuma-runtime";
6+import { ScopeId } from "./ids";
7+import { definePlugin } from "./plugin";
8+import {
9+ definePluginStorageCollection,
10+ type PluginStorageCollectionFacade,
11+ type PluginStorageCollectionQueryInput,
12+ type PluginStorageCollectionWhere,
13+} from "./plugin-storage";
14+import { Scope } from "./scope";
15+import { makeTestConfig, makeTestExecutor } from "./testing";
16+
17+const ToolCall = Schema.Struct({
18+ runId: Schema.String,
19+ toolId: Schema.String,
20+ userId: Schema.NullOr(Schema.String),
21+ clientName: Schema.NullOr(Schema.String),
22+ status: Schema.Literals(["ok", "failed", "blocked"]),
23+ startedAt: Schema.String,
24+ durationMs: Schema.Number,
25+});
26+
27+const toolCalls = definePluginStorageCollection("toolCalls", ToolCall, {
28+ indexes: ["runId", "toolId", "status", "clientName", "startedAt", ["toolId", "startedAt"]],
29+});
30+
31+type ToolCall = typeof ToolCall.Type;
32+
33+const assertPluginStorageTypes = (storage: PluginStorageCollectionFacade<typeof toolCalls>) => {
34+ const validQuery = storage.query({ where: { toolId: "shell" } });
35+
36+ // @ts-expect-error durationMs is part of the data shape but is not declared as an index.
37+ const invalidWhereQuery = storage.query({ where: { durationMs: 100 } });
38+
39+ // @ts-expect-error orderBy is also restricted to declared index fields.
40+ const invalidOrderQuery = storage.query({ orderBy: [{ field: "durationMs" }] });
41+
42+ // @ts-expect-error indexes must point at fields in the collection schema.
43+ definePluginStorageCollection("bad", ToolCall, { indexes: ["missing"] });
44+
45+ void validQuery;
46+ void invalidWhereQuery;
47+ void invalidOrderQuery;
48+};
49+void assertPluginStorageTypes;
50+
51+const uncheckedToolCallWhere = (
52+ where: Readonly<Record<string, unknown>>,
53+): PluginStorageCollectionWhere<typeof toolCalls> =>
54+ where as PluginStorageCollectionWhere<typeof toolCalls>;
55+
56+const executionHistoryPlugin = definePlugin(() => ({
57+ id: "executionHistory" as const,
58+ pluginStorage: { toolCalls },
59+ storage: ({ pluginStorage }) => ({
60+ toolCalls: pluginStorage.collection(toolCalls),
61+ }),
62+ extension: (ctx) => ({
63+ record: (scope: string, key: string, data: ToolCall) =>
64+ ctx.storage.toolCalls.put({ scope, key, data }),
65+ get: (key: string) => ctx.storage.toolCalls.get({ key }),
66+ query: (input?: PluginStorageCollectionQueryInput<typeof toolCalls>) =>
67+ ctx.storage.toolCalls.query(input),
68+ count: (
69+ input?: Omit<
70+ PluginStorageCollectionQueryInput<typeof toolCalls>,
71+ "orderBy" | "limit" | "offset"
72+ >,
73+ ) => ctx.storage.toolCalls.count(input),
74+ queryUnindexed: () =>
75+ ctx.storage.toolCalls.query({
76+ where: uncheckedToolCallWhere({ durationMs: 100 }),
77+ }),
78+ }),
79+}))();
80+
81+const scope = (id: string, name: string) =>
82+ Scope.make({
83+ id: ScopeId.make(id),
84+ name,
85+ createdAt: new Date(),
86+ });
87+
88+const call = (input: {
89+ readonly runId: string;
90+ readonly toolId: string;
91+ readonly status: ToolCall["status"];
92+ readonly startedAt: string;
93+ readonly clientName?: string | null;
94+ readonly userId?: string | null;
95+ readonly durationMs?: number;
96+}): ToolCall => ({
97+ runId: input.runId,
98+ toolId: input.toolId,
99+ userId: input.userId ?? null,
100+ clientName: input.clientName ?? null,
101+ status: input.status,
102+ startedAt: input.startedAt,
103+ durationMs: input.durationMs ?? 0,
104+});
105+
106+describe("plugin storage collections", () => {
107+ it.effect("queries declared indexes through the executor's SQLite FumaDB target", () =>
108+ Effect.gen(function* () {
109+ const executor = yield* makeTestExecutor({
110+ backend: "sqlite",
111+ plugins: [executionHistoryPlugin] as const,
112+ });
113+ const targetScope = "test-scope";
114+
115+ yield* executor.executionHistory.record(
116+ targetScope,
117+ "call-1",
118+ call({
119+ runId: "run-a",
120+ toolId: "browser",
121+ status: "failed",
122+ clientName: "codex",
123+ startedAt: "2026-05-29T10:00:00.000Z",
124+ durationMs: 320,
125+ }),
126+ );
127+ yield* executor.executionHistory.record(
128+ targetScope,
129+ "call-2",
130+ call({
131+ runId: "run-a",
132+ toolId: "shell",
133+ status: "ok",
134+ clientName: "codex",
135+ startedAt: "2026-05-29T10:01:00.000Z",
136+ durationMs: 42,
137+ }),
138+ );
139+ yield* executor.executionHistory.record(
140+ targetScope,
141+ "call-3",
142+ call({
143+ runId: "run-b",
144+ toolId: "shell",
145+ status: "failed",
146+ clientName: "codex",
147+ startedAt: "2026-05-29T10:02:00.000Z",
148+ durationMs: 77,
149+ }),
150+ );
151+
152+ const failed = yield* executor.executionHistory.query({
153+ where: {
154+ clientName: "codex",
155+ status: "failed",
156+ },
157+ orderBy: [{ field: "startedAt", direction: "desc" }],
158+ limit: 10,
159+ });
160+ expect(failed.map((entry) => entry.key)).toEqual(["call-3", "call-1"]);
161+ expect(failed.map((entry) => entry.data.toolId)).toEqual(["shell", "browser"]);
162+
163+ const shellCount = yield* executor.executionHistory.count({
164+ where: { toolId: "shell" },
165+ });
166+ expect(shellCount).toBe(2);
167+ }),
168+ );
169+
170+ it.effect("uses the executor scope stack while sharing one plugin_storage table", () =>
171+ Effect.gen(function* () {
172+ const org = scope("org", "Org");
173+ const user = scope("user", "User");
174+ const plugins = [executionHistoryPlugin] as const;
175+ const config = makeTestConfig({ backend: "sqlite", plugins, scopes: [org] });
176+
177+ const orgExecutor = yield* createExecutor({
178+ ...config,
179+ scopes: [org],
180+ plugins,
181+ });
182+ yield* orgExecutor.executionHistory.record(
183+ org.id,
184+ "shared",
185+ call({
186+ runId: "run-scope",
187+ toolId: "shell",
188+ status: "ok",
189+ startedAt: "2026-05-29T11:00:00.000Z",
190+ }),
191+ );
192+
193+ const userOnlyExecutor = yield* createExecutor({
194+ ...config,
195+ scopes: [user],
196+ plugins,
197+ });
198+ const userOnlyRows = yield* userOnlyExecutor.executionHistory.query({
199+ where: { runId: "run-scope" },
200+ });
201+ expect(userOnlyRows).toEqual([]);
202+
203+ const stackedExecutor = yield* createExecutor({
204+ ...config,
205+ scopes: [user, org],
206+ plugins,
207+ });
208+ yield* stackedExecutor.executionHistory.record(
209+ user.id,
210+ "shared",
211+ call({
212+ runId: "run-scope",
213+ toolId: "browser",
214+ status: "failed",
215+ startedAt: "2026-05-29T11:01:00.000Z",
216+ }),
217+ );
218+
219+ const visibleShared = yield* stackedExecutor.executionHistory.get("shared");
220+ expect(visibleShared?.scopeId).toBe(user.id);
221+ expect(visibleShared?.data.toolId).toBe("browser");
222+
223+ const scopedRows = yield* stackedExecutor.executionHistory.query({
224+ where: { runId: "run-scope" },
225+ orderBy: [{ field: "startedAt" }],
226+ });
227+ expect(
228+ scopedRows.map((entry) => [entry.key, String(entry.scopeId), entry.data.toolId]),
229+ ).toEqual([
230+ ["shared", org.id, "shell"],
231+ ["shared", user.id, "browser"],
232+ ]);
233+ }),
234+ );
235+
236+ it.effect("rejects runtime queries against undeclared index fields", () =>
237+ Effect.gen(function* () {
238+ const executor = yield* makeTestExecutor({
239+ backend: "sqlite",
240+ plugins: [executionHistoryPlugin] as const,
241+ });
242+
243+ const exit = yield* Effect.exit(executor.executionHistory.queryUnindexed());
244+ expect(Exit.isFailure(exit)).toBe(true);
245+ if (!Exit.isFailure(exit)) return;
246+
247+ const reason = exit.cause.reasons.find(Cause.isFailReason);
248+ expect(reason?.error).toBeInstanceOf(StorageError);
249+ expect(reason?.error).toMatchObject({
250+ message:
251+ 'Plugin storage collection "toolCalls" cannot query field "durationMs" because it is not declared as an index',
252+ });
253+ }),
254+ );
255+});
packages/core/sdk/src/plugin-storage.ts+159−0
@@ -3,6 +3,98 @@ import { Effect } from "effect"; · 2 unmodified lines
33 import type { StorageFailure } from "./fuma-runtime";
44 import type { ScopeId } from "./ids";
55
6+export type PluginStorageSchema = {
7+ readonly Type: object;
8+};
9+
10+export type PluginStorageSchemaType<TSchema extends PluginStorageSchema> = TSchema["Type"];
11+
12+export type PluginStorageIndexField<TData extends object> = Extract<keyof TData, string>;
13+
14+export type PluginStorageIndexSpec<TData extends object> =
15+ | PluginStorageIndexField<TData>
16+ | readonly PluginStorageIndexField<TData>[];
17+
18+export type PluginStorageRuntimeIndexSpec = string | readonly string[];
19+
20+export interface PluginStorageRuntimeCollectionDefinition {
21+ readonly name: string;
22+ readonly schema: PluginStorageSchema;
23+ readonly indexes: readonly PluginStorageRuntimeIndexSpec[];
24+}
25+
26+export interface PluginStorageCollectionDefinition<
27+ TName extends string = string,
28+ TData extends object = Record<string, unknown>,
29+ TIndexes extends readonly PluginStorageIndexSpec<TData>[] =
30+ readonly PluginStorageIndexSpec<TData>[],
31+> extends PluginStorageRuntimeCollectionDefinition {
32+ readonly name: TName;
33+ readonly schema: PluginStorageSchema;
34+ readonly indexes: TIndexes;
35+}
36+
37+export type PluginStorageConfig = Readonly<
38+ Record<string, PluginStorageRuntimeCollectionDefinition>
39+>;
40+
41+export const definePluginStorageCollection = <
42+ const TName extends string,
43+ const TSchema extends PluginStorageSchema,
44+ const TIndexes extends readonly PluginStorageIndexSpec<PluginStorageSchemaType<TSchema>>[] =
45+ readonly [],
46+>(
47+ name: TName,
48+ schema: TSchema,
49+ options?: {
50+ readonly indexes?: TIndexes;
51+ },
52+): PluginStorageCollectionDefinition<TName, PluginStorageSchemaType<TSchema>, TIndexes> => ({
53+ name,
54+ schema,
55+ indexes: (options?.indexes ?? []) as TIndexes,
56+});
57+
58+export type PluginStorageCollectionData<TDefinition> =
59+ TDefinition extends PluginStorageCollectionDefinition<infer _Name, infer TData, infer _Indexes>
60+ ? TData
61+ : never;
62+
63+export type PluginStorageIndexFields<TIndexes> = TIndexes extends readonly (infer TIndex)[]
64+ ? TIndex extends readonly (infer TField)[]
65+ ? Extract<TField, string>
66+ : Extract<TIndex, string>
67+ : never;
68+
69+export type PluginStorageCollectionIndexedField<TDefinition> =
70+ TDefinition extends PluginStorageCollectionDefinition<infer _Name, infer _Data, infer TIndexes>
71+ ? PluginStorageIndexFields<TIndexes>
72+ : never;
73+
74+export interface PluginStorageWhereFilter<TValue> {
75+ readonly eq?: TValue;
76+ readonly in?: readonly TValue[];
77+ readonly gt?: TValue;
78+ readonly gte?: TValue;
79+ readonly lt?: TValue;
80+ readonly lte?: TValue;
81+}
82+
83+export type PluginStorageWhereValue<TValue> = TValue | PluginStorageWhereFilter<TValue>;
84+
85+export type PluginStorageCollectionWhere<TDefinition> = {
86+ readonly [TField in PluginStorageCollectionIndexedField<TDefinition>]?: PluginStorageWhereValue<
87+ TField extends keyof PluginStorageCollectionData<TDefinition>
88+ ? PluginStorageCollectionData<TDefinition>[TField]
89+ : never
90+ >;
91+};
92+
93+export interface PluginStorageCollectionOrderBy<TDefinition> {
94+ readonly field: PluginStorageCollectionIndexedField<TDefinition>;
95+ readonly direction?: "asc" | "desc";
96+}
97+
698 export interface PluginStorageKeyInput {
799 readonly collection: string;
8100 readonly key: string;
@@ -21,6 +113,32 @@ export interface PluginStoragePutInput extends PluginStorageScopedKeyInput { · 12 unmodified lines
21113 readonly data: unknown;
22114 }
23115
116+export interface PluginStorageCollectionKeyInput {
117+ readonly key: string;
118+}
119+
120+export interface PluginStorageCollectionScopedKeyInput extends PluginStorageCollectionKeyInput {
121+ readonly scope: ScopeId | string;
122+}
123+
124+export interface PluginStorageCollectionListInput {
125+ readonly keyPrefix?: string;
126+}
127+
128+export interface PluginStorageCollectionPutInput<
129+ TData extends object,
130+> extends PluginStorageCollectionScopedKeyInput {
131+ readonly data: TData;
132+}
133+
134+export interface PluginStorageCollectionQueryInput<TDefinition> {
135+ readonly keyPrefix?: string;
136+ readonly where?: PluginStorageCollectionWhere<TDefinition>;
137+ readonly orderBy?: readonly PluginStorageCollectionOrderBy<TDefinition>[];
138+ readonly limit?: number;
139+ readonly offset?: number;
140+}
141+
24142 export interface PluginStorageEntry<T = unknown> {
25143 readonly id: string;
26144 readonly scopeId: ScopeId | string;
@@ -32,7 +150,48 @@ export interface PluginStorageEntry<T = unknown> { · 5 unmodified lines
32150 readonly updatedAt: Date;
33151 }
34152
153+export interface PluginStorageCollectionFacade<
154+ TDefinition extends PluginStorageCollectionDefinition = PluginStorageCollectionDefinition,
155+> {
156+ readonly get: (
157+ input: PluginStorageCollectionKeyInput,
158+ ) => Effect.Effect<
159+ PluginStorageEntry<PluginStorageCollectionData<TDefinition>> | null,
160+ StorageFailure
161+ >;
162+ readonly getAtScope: (
163+ input: PluginStorageCollectionScopedKeyInput,
164+ ) => Effect.Effect<
165+ PluginStorageEntry<PluginStorageCollectionData<TDefinition>> | null,
166+ StorageFailure
167+ >;
168+ readonly list: (
169+ input?: PluginStorageCollectionListInput,
170+ ) => Effect.Effect<
171+ readonly PluginStorageEntry<PluginStorageCollectionData<TDefinition>>[],
172+ StorageFailure
173+ >;
174+ readonly put: (
175+ input: PluginStorageCollectionPutInput<PluginStorageCollectionData<TDefinition>>,
176+ ) => Effect.Effect<PluginStorageEntry<PluginStorageCollectionData<TDefinition>>, StorageFailure>;
177+ readonly query: (
178+ input?: PluginStorageCollectionQueryInput<TDefinition>,
179+ ) => Effect.Effect<
180+ readonly PluginStorageEntry<PluginStorageCollectionData<TDefinition>>[],
181+ StorageFailure
182+ >;
183+ readonly count: (
184+ input?: Omit<PluginStorageCollectionQueryInput<TDefinition>, "orderBy" | "limit" | "offset">,
185+ ) => Effect.Effect<number, StorageFailure>;
186+ readonly remove: (
187+ input: PluginStorageCollectionScopedKeyInput,
188+ ) => Effect.Effect<void, StorageFailure>;
189+}
190+
35191 export interface PluginStorageFacade {
192+ readonly collection: <const TDefinition extends PluginStorageCollectionDefinition>(
193+ definition: TDefinition,
194+ ) => PluginStorageCollectionFacade<TDefinition>;
36195 readonly get: <T = unknown>(
37196 input: PluginStorageKeyInput,
38197 ) => Effect.Effect<PluginStorageEntry<T> | null, StorageFailure>;
packages/core/sdk/src/plugin.ts+7−1
@@ -42,7 +42,7 @@ import type { · 41 unmodified lines
4242 SourceRemovalNotAllowedError,
4343 } from "./errors";
4444 import type { OAuthService } from "./oauth";
45−import type { PluginStorageFacade } from "./plugin-storage";
45+import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
4646 import type {
4747 CreateToolPolicyInput,
4848 RemoveToolPolicyInput,
@@ -497,6 +497,12 @@ export interface PluginSpec< · 448 unmodified lines
497497 * across plugins are structurally impossible. */
498498 readonly storage: (deps: StorageDeps) => TStore;
499499
500+ /** Host-owned plugin storage declarations. Plugins declare logical
501+ * collections and indexed JSON fields here; data still lives in the
502+ * executor's shared `plugin_storage` table instead of per-plugin
503+ * adapter schemas. */
504+ readonly pluginStorage?: PluginStorageConfig;
505+
500506 /** JSON-serializable config the plugin wants its `./client` bundle to
501507 * see. The Vite plugin reads this off each `executor.config.ts` spec
502508 * at build time and bakes it into the virtual `plugins-client`
packages/core/sdk/src/shared.ts+20−0
@@ -57,13 +57,33 @@ export { · 56 unmodified lines
5757 } from "./credential-bindings";
5858
5959 export {
60+ definePluginStorageCollection,
6061 pluginStorageId,
62+ type PluginStorageCollectionDefinition,
63+ type PluginStorageCollectionFacade,
64+ type PluginStorageCollectionIndexedField,
65+ type PluginStorageCollectionKeyInput,
66+ type PluginStorageCollectionListInput,
67+ type PluginStorageCollectionOrderBy,
68+ type PluginStorageCollectionPutInput,
69+ type PluginStorageCollectionQueryInput,
70+ type PluginStorageCollectionScopedKeyInput,
71+ type PluginStorageCollectionWhere,
72+ type PluginStorageConfig,
6173 type PluginStorageEntry,
6274 type PluginStorageFacade,
75+ type PluginStorageIndexField,
76+ type PluginStorageIndexSpec,
6377 type PluginStorageKeyInput,
6478 type PluginStorageListInput,
6579 type PluginStoragePutInput,
80+ type PluginStorageRuntimeCollectionDefinition,
81+ type PluginStorageRuntimeIndexSpec,
82+ type PluginStorageSchema,
83+ type PluginStorageSchemaType,
6684 type PluginStorageScopedKeyInput,
85+ type PluginStorageWhereFilter,
86+ type PluginStorageWhereValue,
6787 } from "./plugin-storage";
6888
6989 export { SourceDetectionResult, type Source } from "./types";