Unified · Split
| @@ -120,8 +120,13 @@ import type { · 119 unmodified lines | |||
| 120 | } from "./plugin"; | 120 | } from "./plugin"; |
| 121 | import { | 121 | import { |
| 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"; |
| 126 | import type { Scope } from "./scope"; | 131 | import type { Scope } from "./scope"; |
| 127 | import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; | 132 | import { 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 | ||
| 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 | |||
| 804 | const makePluginStorageFacade = (input: { | 911 | const 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.core | 1092 | input.core |
| @@ -199,6 +199,37 @@ export { · 198 unmodified lines | |||
| 199 | makeInMemoryBlobStore, | 199 | makeInMemoryBlobStore, |
| 200 | } from "./blob"; | 200 | } from "./blob"; |
| 201 | 201 | ||
| 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 | |||
| 202 | // OAuth 2.1 | 233 | // OAuth 2.1 |
| 203 | export { | 234 | export { |
| 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"; |
| 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"; | ||
| 332 | 354 | ||
| 333 | // Executor | 355 | // Executor |
| 334 | export { | 356 | export { |
| @@ -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 | }); | ||
| @@ -3,6 +3,98 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | import type { StorageFailure } from "./fuma-runtime"; | 3 | import type { StorageFailure } from "./fuma-runtime"; |
| 4 | import type { ScopeId } from "./ids"; | 4 | import type { ScopeId } from "./ids"; |
| 5 | 5 | ||
| 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 | |||
| 6 | export interface PluginStorageKeyInput { | 98 | export 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 | ||
| 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 | |||
| 24 | export interface PluginStorageEntry<T = unknown> { | 142 | export 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 | ||
| 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 | |||
| 35 | export interface PluginStorageFacade { | 191 | export 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>; |
| @@ -42,7 +42,7 @@ import type { · 41 unmodified lines | |||
| 42 | SourceRemovalNotAllowedError, | 42 | SourceRemovalNotAllowedError, |
| 43 | } from "./errors"; | 43 | } from "./errors"; |
| 44 | import type { OAuthService } from "./oauth"; | 44 | import type { OAuthService } from "./oauth"; |
| 45 | import type { PluginStorageFacade } from "./plugin-storage"; | 45 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; |
| 46 | import type { | 46 | import 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 to | 506 | /** JSON-serializable config the plugin wants its `./client` bundle to |
| 501 | * see. The Vite plugin reads this off each `executor.config.ts` spec | 507 | * 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` |
| @@ -57,13 +57,33 @@ export { · 56 unmodified lines | |||
| 57 | } from "./credential-bindings"; | 57 | } from "./credential-bindings"; |
| 58 | 58 | ||
| 59 | export { | 59 | export { |
| 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 | ||
| 69 | export { SourceDetectionResult, type Source } from "./types"; | 89 | export { SourceDetectionResult, type Source } from "./types"; |