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
120} from "./plugin";120} from "./plugin";
121import {121import {
122 pluginStorageId,122 pluginStorageId,
123 type PluginStorageCollectionData,
124 type PluginStorageCollectionDefinition,
125 type PluginStorageCollectionQueryInput,
123 type PluginStorageEntry,126 type PluginStorageEntry,
124 type PluginStorageFacade,127 type PluginStorageFacade,
128 type PluginStorageRuntimeCollectionDefinition,
129 type PluginStorageRuntimeIndexSpec,
125} from "./plugin-storage";130} from "./plugin-storage";
126import type { Scope } from "./scope";131import type { Scope } from "./scope";
127import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets";132import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets";
@@ -801,6 +806,108 @@ const pluginStorageEntryFromRow = <T>(row: CoreRow<"plugin_storage">): PluginSto · 673 unmodified lines
801 updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at),806 updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at),
802});807});
803 808
809const pluginStorageIndexSpecFields = (spec: PluginStorageRuntimeIndexSpec): readonly string[] =>
810 typeof spec === "string" ? [spec] : spec;
811
812const pluginStorageCollectionIndexedFields = (
813 definition: PluginStorageRuntimeCollectionDefinition,
814): ReadonlySet<string> =>
815 new Set(definition.indexes.flatMap((spec) => pluginStorageIndexSpecFields(spec)));
816
817const 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
853const isPluginStorageRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
854 value !== null && typeof value === "object" && !Array.isArray(value);
855
856const pluginStorageWhereOperators = ["eq", "in", "gt", "gte", "lt", "lte"] as const;
857
858const isPluginStorageWhereFilter = (value: unknown): value is Readonly<Record<string, unknown>> =>
859 isPluginStorageRecord(value) && pluginStorageWhereOperators.some((operator) => operator in value);
860
861const 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
870const 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
882const pluginStorageDataField = (data: unknown, field: string): unknown =>
883 isPluginStorageRecord(data) ? data[field] : undefined;
884
885const 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
901const 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
804const makePluginStorageFacade = (input: {911const makePluginStorageFacade = (input: {
805 readonly core: ReturnType<typeof makeCoreDb>;912 readonly core: ReturnType<typeof makeCoreDb>;
806 readonly pluginId: string;913 readonly pluginId: string;
@@ -828,7 +935,158 @@ const makePluginStorageFacade = (input: { · 21 unmodified lines
828 .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null))935 .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null))
829 .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)));936 .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)));
830 937
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
831 return {994 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 }),
832 get: (storageInput) => getVisible(storageInput.collection, storageInput.key),1090 get: (storageInput) => getVisible(storageInput.collection, storageInput.key),
833 getAtScope: (storageInput) =>1091 getAtScope: (storageInput) =>
834 input.core1092 input.core
packages/core/sdk/src/index.ts+31−9
@@ -199,6 +199,37 @@ export { · 198 unmodified lines
199 makeInMemoryBlobStore,199 makeInMemoryBlobStore,
200} from "./blob";200} from "./blob";
201 201
202// Plugin storage
203export {
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
202// OAuth 2.1233// OAuth 2.1
203export {234export {
204 type OAuthService,235 type OAuthService,
@@ -320,15 +351,6 @@ export { · 115 unmodified lines
320 definePlugin,351 definePlugin,
321 tool,352 tool,
322} from "./plugin";353} from "./plugin";
323export {
324 pluginStorageId,
325 type PluginStorageEntry,
326 type PluginStorageFacade,
327 type PluginStorageKeyInput,
328 type PluginStorageListInput,
329 type PluginStoragePutInput,
330 type PluginStorageScopedKeyInput,
331} from "./plugin-storage";
332 354
333// Executor355// Executor
334export {356export {
packages/core/sdk/src/plugin-storage.test.ts+255−0
@@ -0,0 +1,255 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Cause, Effect, Exit, Schema } from "effect";
3
4import { createExecutor } from "./executor";
5import { StorageError } from "./fuma-runtime";
6import { ScopeId } from "./ids";
7import { definePlugin } from "./plugin";
8import {
9 definePluginStorageCollection,
10 type PluginStorageCollectionFacade,
11 type PluginStorageCollectionQueryInput,
12 type PluginStorageCollectionWhere,
13} from "./plugin-storage";
14import { Scope } from "./scope";
15import { makeTestConfig, makeTestExecutor } from "./testing";
16
17const 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
27const toolCalls = definePluginStorageCollection("toolCalls", ToolCall, {
28 indexes: ["runId", "toolId", "status", "clientName", "startedAt", ["toolId", "startedAt"]],
29});
30
31type ToolCall = typeof ToolCall.Type;
32
33const 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};
49void assertPluginStorageTypes;
50
51const uncheckedToolCallWhere = (
52 where: Readonly<Record<string, unknown>>,
53): PluginStorageCollectionWhere<typeof toolCalls> =>
54 where as PluginStorageCollectionWhere<typeof toolCalls>;
55
56const 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
81const scope = (id: string, name: string) =>
82 Scope.make({
83 id: ScopeId.make(id),
84 name,
85 createdAt: new Date(),
86 });
87
88const 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
106describe("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
3import type { StorageFailure } from "./fuma-runtime";3import type { StorageFailure } from "./fuma-runtime";
4import type { ScopeId } from "./ids";4import type { ScopeId } from "./ids";
5 5
6export type PluginStorageSchema = {
7 readonly Type: object;
8};
9
10export type PluginStorageSchemaType<TSchema extends PluginStorageSchema> = TSchema["Type"];
11
12export type PluginStorageIndexField<TData extends object> = Extract<keyof TData, string>;
13
14export type PluginStorageIndexSpec<TData extends object> =
15 | PluginStorageIndexField<TData>
16 | readonly PluginStorageIndexField<TData>[];
17
18export type PluginStorageRuntimeIndexSpec = string | readonly string[];
19
20export interface PluginStorageRuntimeCollectionDefinition {
21 readonly name: string;
22 readonly schema: PluginStorageSchema;
23 readonly indexes: readonly PluginStorageRuntimeIndexSpec[];
24}
25
26export 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
37export type PluginStorageConfig = Readonly<
38 Record<string, PluginStorageRuntimeCollectionDefinition>
39>;
40
41export 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
58export type PluginStorageCollectionData<TDefinition> =
59 TDefinition extends PluginStorageCollectionDefinition<infer _Name, infer TData, infer _Indexes>
60 ? TData
61 : never;
62
63export 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
69export type PluginStorageCollectionIndexedField<TDefinition> =
70 TDefinition extends PluginStorageCollectionDefinition<infer _Name, infer _Data, infer TIndexes>
71 ? PluginStorageIndexFields<TIndexes>
72 : never;
73
74export 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
83export type PluginStorageWhereValue<TValue> = TValue | PluginStorageWhereFilter<TValue>;
84
85export 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
93export interface PluginStorageCollectionOrderBy<TDefinition> {
94 readonly field: PluginStorageCollectionIndexedField<TDefinition>;
95 readonly direction?: "asc" | "desc";
96}
97
6export interface PluginStorageKeyInput {98export interface PluginStorageKeyInput {
7 readonly collection: string;99 readonly collection: string;
8 readonly key: string;100 readonly key: string;
@@ -21,6 +113,32 @@ export interface PluginStoragePutInput extends PluginStorageScopedKeyInput { · 12 unmodified lines
21 readonly data: unknown;113 readonly data: unknown;
22}114}
23 115
116export interface PluginStorageCollectionKeyInput {
117 readonly key: string;
118}
119
120export interface PluginStorageCollectionScopedKeyInput extends PluginStorageCollectionKeyInput {
121 readonly scope: ScopeId | string;
122}
123
124export interface PluginStorageCollectionListInput {
125 readonly keyPrefix?: string;
126}
127
128export interface PluginStorageCollectionPutInput<
129 TData extends object,
130> extends PluginStorageCollectionScopedKeyInput {
131 readonly data: TData;
132}
133
134export 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
24export interface PluginStorageEntry<T = unknown> {142export interface PluginStorageEntry<T = unknown> {
25 readonly id: string;143 readonly id: string;
26 readonly scopeId: ScopeId | string;144 readonly scopeId: ScopeId | string;
@@ -32,7 +150,48 @@ export interface PluginStorageEntry<T = unknown> { · 5 unmodified lines
32 readonly updatedAt: Date;150 readonly updatedAt: Date;
33}151}
34 152
153export 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
35export interface PluginStorageFacade {191export interface PluginStorageFacade {
192 readonly collection: <const TDefinition extends PluginStorageCollectionDefinition>(
193 definition: TDefinition,
194 ) => PluginStorageCollectionFacade<TDefinition>;
36 readonly get: <T = unknown>(195 readonly get: <T = unknown>(
37 input: PluginStorageKeyInput,196 input: PluginStorageKeyInput,
38 ) => Effect.Effect<PluginStorageEntry<T> | null, StorageFailure>;197 ) => Effect.Effect<PluginStorageEntry<T> | null, StorageFailure>;
packages/core/sdk/src/plugin.ts+7−1
@@ -42,7 +42,7 @@ import type { · 41 unmodified lines
42 SourceRemovalNotAllowedError,42 SourceRemovalNotAllowedError,
43} from "./errors";43} from "./errors";
44import type { OAuthService } from "./oauth";44import type { OAuthService } from "./oauth";
45import type { PluginStorageFacade } from "./plugin-storage";45import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
46import type {46import type {
47 CreateToolPolicyInput,47 CreateToolPolicyInput,
48 RemoveToolPolicyInput,48 RemoveToolPolicyInput,
@@ -497,6 +497,12 @@ export interface PluginSpec< · 448 unmodified lines
497 * across plugins are structurally impossible. */497 * across plugins are structurally impossible. */
498 readonly storage: (deps: StorageDeps) => TStore;498 readonly storage: (deps: StorageDeps) => TStore;
499 499
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
500 /** JSON-serializable config the plugin wants its `./client` bundle to506 /** JSON-serializable config the plugin wants its `./client` bundle to
501 * see. The Vite plugin reads this off each `executor.config.ts` spec507 * see. The Vite plugin reads this off each `executor.config.ts` spec
502 * at build time and bakes it into the virtual `plugins-client`508 * 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
57} from "./credential-bindings";57} from "./credential-bindings";
58 58
59export {59export {
60 definePluginStorageCollection,
60 pluginStorageId,61 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,
61 type PluginStorageEntry,73 type PluginStorageEntry,
62 type PluginStorageFacade,74 type PluginStorageFacade,
75 type PluginStorageIndexField,
76 type PluginStorageIndexSpec,
63 type PluginStorageKeyInput,77 type PluginStorageKeyInput,
64 type PluginStorageListInput,78 type PluginStorageListInput,
65 type PluginStoragePutInput,79 type PluginStoragePutInput,
80 type PluginStorageRuntimeCollectionDefinition,
81 type PluginStorageRuntimeIndexSpec,
82 type PluginStorageSchema,
83 type PluginStorageSchemaType,
66 type PluginStorageScopedKeyInput,84 type PluginStorageScopedKeyInput,
85 type PluginStorageWhereFilter,
86 type PluginStorageWhereValue,
67} from "./plugin-storage";87} from "./plugin-storage";
68 88
69export { SourceDetectionResult, type Source } from "./types";89export { SourceDetectionResult, type Source } from "./types";