From 220a8b24c98acc77e5c7055a655fc023b9f8e3ce Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 23:05:56 -0700 Subject: [PATCH 1/2] Remove per-plugin storage schemas --- .../0019_workos_vault_plugin_storage.sql | 27 ++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/services/executor-schema.ts | 11 - apps/local/executor.config.ts | 4 +- apps/local/src/server/sqlite-import.test.ts | 33 +- packages/core/cli/README.md | 4 +- packages/core/sdk/src/config.ts | 3 +- packages/core/sdk/src/executor.test.ts | 68 ++-- packages/core/sdk/src/executor.ts | 34 +- packages/core/sdk/src/plugin.ts | 56 +-- packages/core/sdk/src/policies.test.ts | 7 +- packages/core/sdk/src/scope-policy.test.ts | 325 +----------------- packages/plugins/file-secrets/src/promise.ts | 2 +- packages/plugins/graphql/src/sdk/index.ts | 2 - packages/plugins/graphql/src/sdk/plugin.ts | 2 - packages/plugins/graphql/src/sdk/store.ts | 8 +- packages/plugins/keychain/src/promise.ts | 3 +- packages/plugins/mcp/src/sdk/binding-store.ts | 6 +- packages/plugins/mcp/src/sdk/index.ts | 8 +- packages/plugins/mcp/src/sdk/plugin.ts | 8 +- packages/plugins/openapi/src/sdk/index.ts | 2 - packages/plugins/openapi/src/sdk/plugin.ts | 2 - packages/plugins/openapi/src/sdk/store.ts | 8 +- .../plugins/workos-vault/src/sdk/index.ts | 2 - .../plugins/workos-vault/src/sdk/plugin.ts | 2 - .../workos-vault/src/sdk/secret-store.test.ts | 22 +- .../workos-vault/src/sdk/secret-store.ts | 141 ++++---- 27 files changed, 192 insertions(+), 605 deletions(-) create mode 100644 apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql diff --git a/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql b/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql new file mode 100644 index 000000000..7570b5fb0 --- /dev/null +++ b/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql @@ -0,0 +1,27 @@ +INSERT INTO "plugin_storage" ( + "row_id", + "id", + "scope_id", + "plugin_id", + "collection", + "key", + "data", + "created_at", + "updated_at" +) +SELECT + 'plugin_storage_' || md5('workosVault:metadata:' || m."scope_id" || ':' || m."id"), + '["workosVault","metadata",' || to_json(m."id")::text || ']', + m."scope_id", + 'workosVault', + 'metadata', + m."id", + json_build_object('name', m."name", 'purpose', m."purpose", 'createdAt', m."created_at"), + m."created_at", + now() +FROM "workos_vault_metadata" m +ON CONFLICT ("scope_id", "id") DO UPDATE SET + "data" = EXCLUDED."data", + "updated_at" = EXCLUDED."updated_at"; + +DROP TABLE IF EXISTS "workos_vault_metadata"; diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 5a52cfb8a..04b1d990c 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1779199200000, "tag": "0018_repair_openapi_oauth_authorization_url", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1780081200000, + "tag": "0019_workos_vault_plugin_storage", + "breakpoints": true } ] } diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts index e4c9eb815..69cd41a18 100644 --- a/apps/cloud/src/services/executor-schema.ts +++ b/apps/cloud/src/services/executor-schema.ts @@ -148,17 +148,6 @@ export const blob = pgTable("blob", { uniqueIndex("blob_id_uidx").on(table.id) ]) -export const workos_vault_metadata = pgTable("workos_vault_metadata", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - purpose: text("purpose"), - created_at: timestamp("created_at").notNull() -}, (table) => [ - uniqueIndex("workos_vault_metadata_scope_id_id_uidx").on(table.scope_id, table.id) -]) - export const private_executor_cloud_settings = pgTable("private_executor_cloud_settings", { id: varchar("id", { length: 255 }).primaryKey().notNull(), version: varchar("version", { length: 255 }).notNull().default("1.0.0") diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index e9a70b548..f932e372a 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -10,8 +10,8 @@ import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/serv // --------------------------------------------------------------------------- // Single source of truth for the local app's plugin list. // -// Consumed by the host runtime. The runtime passes the merged plugin tables -// to FumaDB directly; there is no separate Executor schema-generation step. +// Consumed by the host runtime. Executor owns the storage tables; plugins use +// host-provided storage facades instead of contributing schema. // // First-party and third-party plugins use the same import-and-call flow. // --------------------------------------------------------------------------- diff --git a/apps/local/src/server/sqlite-import.test.ts b/apps/local/src/server/sqlite-import.test.ts index 0e00e6128..87649929c 100644 --- a/apps/local/src/server/sqlite-import.test.ts +++ b/apps/local/src/server/sqlite-import.test.ts @@ -9,12 +9,12 @@ import { boolColumn, collectTables, dateColumn, - definePlugin, jsonColumn, nullableBigintColumn, nullableTextColumn, scopedExecutorTable, textColumn, + type FumaTables, } from "@executor-js/sdk"; import { withQueryContext } from "fumadb/query"; @@ -164,12 +164,6 @@ const lateSchema = { }), }; -const latePlugin = definePlugin(() => ({ - id: "late" as const, - schema: lateSchema, - storage: () => ({}), -}))(); - const ImportMarkerForTest = Schema.Struct({ importedTables: Schema.Array(Schema.String), }); @@ -187,12 +181,6 @@ const legacyShapeSchema = { }), }; -const legacyShapePlugin = definePlugin(() => ({ - id: "legacy-shape" as const, - schema: legacyShapeSchema, - storage: () => ({}), -}))(); - describe("importSqliteDataToFuma", () => { it("imports current SQLite rows into FumaDB SQLite without replacing source files", async () => { const sqlitePath = join(workDir, "data.db"); @@ -352,7 +340,10 @@ describe("importSqliteDataToFuma", () => { ); db.close(); - const tables = collectTables([legacyShapePlugin]); + const tables: FumaTables = { + ...collectTables([]), + ...legacyShapeSchema, + }; sqlite = await createSqliteFumaDb({ tables, namespace: "executor_local_test", @@ -492,7 +483,7 @@ describe("importSqliteDataToFuma", () => { ).resolves.toMatchObject({ id: "src_1", scope_id: "scope_a" }); }); - it("imports newly-loaded plugin tables from the original backup after the first cutover", async () => { + it("imports newly-available tables from the original backup after the first cutover", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); seedMigratedSqlite(sqlitePath); @@ -523,7 +514,10 @@ describe("importSqliteDataToFuma", () => { }); expect(firstResult.importedTables).not.toContain("late_item"); - const allTables = collectTables([latePlugin]); + const allTables: FumaTables = { + ...collectTables([]), + ...lateSchema, + }; const secondResult = await importLegacySqliteIfNeeded({ storage: { dataDir: workDir, @@ -549,7 +543,7 @@ describe("importSqliteDataToFuma", () => { ).resolves.toEqual([{ id: "late_1", value: "from-backup" }]); }); - it("marks newly-loaded empty plugin tables so startup does not retry backup imports", async () => { + it("marks newly-available empty tables so startup does not retry backup imports", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); seedMigratedSqlite(sqlitePath); @@ -565,7 +559,10 @@ describe("importSqliteDataToFuma", () => { }); expect(firstResult.importedTables).not.toContain("late_item"); - const allTables = collectTables([latePlugin]); + const allTables: FumaTables = { + ...collectTables([]), + ...lateSchema, + }; const secondResult = await importLegacySqliteIfNeeded({ storage: { dataDir: workDir, diff --git a/packages/core/cli/README.md b/packages/core/cli/README.md index a1754c34a..3739542fe 100644 --- a/packages/core/cli/README.md +++ b/packages/core/cli/README.md @@ -4,4 +4,6 @@ Minimal command-line entrypoint for Executor projects. Schema generation and migrations are owned by FumaDB now. Hosts should build a FumaDB client from `collectTables(plugins)` and use FumaDB's adapter/migrator -APIs directly instead of generating Executor-specific storage adapters. +APIs directly instead of generating Executor-specific storage adapters. Plugins +persist through Executor's host-owned storage facades rather than contributing +tables. diff --git a/packages/core/sdk/src/config.ts b/packages/core/sdk/src/config.ts index 3eae80d1d..1dae59002 100644 --- a/packages/core/sdk/src/config.ts +++ b/packages/core/sdk/src/config.ts @@ -9,8 +9,7 @@ // `configFile` sink, which is keyed to the active scope cwd and so can't // be constructed at module-eval time). Deps are optional — the // packaging and static tooling call `plugins()` with no args (they read -// `plugin.schema` / `plugin.packageName` only); runtime callers pass concrete -// deps. +// `plugin.packageName` only); runtime callers pass concrete deps. // // Each app declares its own deps shape inline on the factory parameter // — TS infers `TDeps` from there, so apps don't reach into the SDK's diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index fbcd99962..48d2c95fd 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Predicate, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; -import { scopedExecutorTable, textColumn } from "./core-schema"; import { ElicitationResponse } from "./elicitation"; import { ToolNotFoundError } from "./errors"; import { createExecutor } from "./executor"; @@ -27,35 +26,34 @@ const testScope = Scope.make({ createdAt: new Date(), }); -const txSchema = { - executor_tx_item: scopedExecutorTable("executor_tx_item", { - value: textColumn("value"), - }), -}; - -type TxItemRow = { - readonly id: string; - readonly scope_id: string; - readonly value: string; -}; - const txPlugin = definePlugin(() => ({ id: "tx" as const, - schema: txSchema, - storage: ({ fuma }) => ({ - create: (row: TxItemRow) => - fuma.use("tx.item.create", (db) => db.create("executor_tx_item", row)).pipe(Effect.asVoid), + storage: ({ pluginStorage }) => ({ + create: (input: { readonly id: string; readonly scope: string; readonly value: string }) => + pluginStorage + .put({ + collection: "item", + key: input.id, + scope: input.scope, + data: { value: input.value }, + }) + .pipe(Effect.asVoid), list: () => - fuma.use("tx.item.list", (db) => - db.findMany("executor_tx_item", { - select: ["id", "scope_id", "value"], - orderBy: ["id", "asc"], - }), + pluginStorage.list<{ readonly value: string }>({ collection: "item" }).pipe( + Effect.map((rows) => + rows + .map((row) => ({ + id: row.key, + scope_id: String(row.scopeId), + value: row.data.value, + })) + .sort((a, b) => a.id.localeCompare(b.id)), + ), ), }), extension: (ctx) => ({ seed: (id: string, value: string, scope = String(ctx.scopes[0]!.id)) => - ctx.storage.create({ id, scope_id: scope, value }), + ctx.storage.create({ id, scope, value }), list: () => ctx.storage.list(), failAfterPluginAndCoreWrites: () => ctx.transaction( @@ -63,7 +61,7 @@ const txPlugin = definePlugin(() => ({ const scope = String(ctx.scopes[0]!.id); yield* ctx.storage.create({ id: "tx-row", - scope_id: scope, + scope, value: "created-before-failure", }); yield* ctx.core.sources.register({ @@ -76,17 +74,6 @@ const txPlugin = definePlugin(() => ({ return yield* new TestPluginError({ message: "rollback" }); }), ), - catchDuplicateCreate: () => - Effect.gen(function* () { - const scope = String(ctx.scopes[0]!.id); - yield* ctx.storage.create({ id: "dup", scope_id: scope, value: "first" }); - return yield* ctx.storage.create({ id: "dup", scope_id: scope, value: "second" }).pipe( - Effect.as({ caught: false as const, model: null as string | null }), - Effect.catchTag("UniqueViolationError", (error) => - Effect.succeed({ caught: true as const, model: error.model ?? null }), - ), - ); - }), }), }))(); @@ -261,17 +248,6 @@ describe("createExecutor", () => { }), ); - it.effect("keeps FumaDB unique violations catchable inside plugin code", () => - Effect.gen(function* () { - const executor = yield* makeTestExecutor({ plugins: [txPlugin] as const }); - - const result = yield* executor.tx.catchDuplicateCreate(); - - expect(result.caught).toBe(true); - expect(result.model).toContain("tx.item.create"); - }), - ); - it.effect("runs plugin and database close hooks", () => Effect.gen(function* () { let pluginClosed = false; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index b7c8c7ad3..c8fac961d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -430,31 +430,14 @@ export interface ExecutorConfig { - const merged: FumaTables = { ...coreSchema }; - for (const plugin of plugins) { - if (!plugin.schema) continue; - for (const [tableKey, tableDef] of Object.entries(plugin.schema)) { - if (merged[tableKey]) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: collectTables is a synchronous configuration API - throw new StorageError({ - message: - `Duplicate storage table "${tableKey}" contributed by plugin "${plugin.id}"` + - ` (reserved by core or another plugin)`, - cause: undefined, - }); - } - merged[tableKey] = tableDef as FumaTables[string]; - } - } - - validateExecutorScopePolicyTables(merged); - - return merged; +export const collectTables = (_plugins: readonly AnyPlugin[]): FumaTables => { + validateExecutorScopePolicyTables(coreSchema); + return { ...coreSchema }; }; const validateExecutorScopePolicyTables = (tables: FumaTables): void => { @@ -3039,10 +3022,6 @@ export const createExecutor = /` so two tenants // sharing a backing BlobStore can't collide or leak on the // same `(plugin, key)` pair. The store's `get`/`has` walk the diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 0f4723b92..ce5194397 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -3,7 +3,7 @@ import type { Context, Layer } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { HttpApiGroup } from "effect/unstable/httpapi"; import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; -import type { FumaTables, IFumaClient, StorageFailure, TablesToFumaSchema } from "./fuma-runtime"; +import type { StorageFailure } from "./fuma-runtime"; import type { PluginBlobStore } from "./blob"; import type { @@ -55,12 +55,11 @@ import type { Usage, UsagesForConnectionInput, UsagesForSecretInput } from "./us // --------------------------------------------------------------------------- // StorageDeps — backing passed to a plugin's `storage` factory. Plugins see -// FumaDB through the Effect boundary, narrowed to their declared tables. Scope -// behavior is domain code, not hidden adapter behavior: reads should include -// `scopedWhere(...)` and writes stamp an explicit `scope_id`. +// host-owned storage facades only. Scope behavior is domain code, not hidden +// adapter behavior: writes name their target scope through facade inputs. // --------------------------------------------------------------------------- -export interface StorageDeps { +export interface StorageDeps { /** * Precedence-ordered scope stack visible to this executor. Innermost * first. Reads on scoped tables walk every scope; writes require the @@ -68,8 +67,6 @@ export interface StorageDeps>; readonly blobs: PluginBlobStore; readonly pluginStorage: PluginStorageFacade; } @@ -466,8 +463,8 @@ export interface SourcePresetCatalogEntry extends SourcePreset { // --------------------------------------------------------------------------- // Defaults are `any` for slots that surface in contravariant positions -// (storage/extension callbacks consume `TStore`/`TSchema`; `staticSources` -// closes over `TExtension` via `NoInfer`). `any` is bivariant, so +// (storage/extension callbacks consume `TStore`; `staticSources` closes +// over `TExtension` via `NoInfer`). `any` is bivariant, so // `Plugin` is a structural supertype of every concrete plugin // — `AnyPlugin = Plugin` keeps the generic explosion contained // to this single declaration. Concrete specs ignore the defaults; TS @@ -481,8 +478,6 @@ export interface PluginSpec< // eslint-disable-next-line @typescript-eslint/no-explicit-any TStore = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - TSchema extends FumaTables | undefined = any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtensionService extends Context.Service | undefined = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any THandlersLayer extends Layer.Layer = any, @@ -497,16 +492,10 @@ export interface PluginSpec< * plugins that ship a `./client` entry; can be omitted for SDK-only * plugins (no client bundle = nothing to resolve). */ readonly packageName?: string; - /** Plugin-declared schema. Merged with coreSchema and other plugins' - * tables at executor startup via `collectTables`. The type flows - * into the `storage` factory's `deps.fuma` as a FumaDB query boundary so - * plugins get narrowed table names + typed rows for free. */ - readonly schema?: TSchema; - /** Build the plugin's typed store from backing. `deps.fuma` is - * already narrowed to this plugin's tables; `deps.blobs` is already - * scoped to the plugin id so key collisions across plugins are - * structurally impossible. */ - readonly storage: (deps: StorageDeps) => TStore; + /** Build the plugin's typed store from host-owned backing. `deps.blobs` + * and `deps.pluginStorage` are scoped to the plugin id so key collisions + * across plugins are structurally impossible. */ + readonly storage: (deps: StorageDeps) => TStore; /** JSON-serializable config the plugin wants its `./client` bundle to * see. The Vite plugin reads this off each `executor.config.ts` spec @@ -700,13 +689,11 @@ export interface Plugin< // eslint-disable-next-line @typescript-eslint/no-explicit-any TStore = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - TSchema extends FumaTables | undefined = any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtensionService extends Context.Service | undefined = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any THandlersLayer extends Layer.Layer = any, TGroup extends HttpApiGroup.Any = HttpApiGroup.Any, -> extends PluginSpec {} +> extends PluginSpec {} // --------------------------------------------------------------------------- // definePlugin — factory-returning-spec. Options from the author factory @@ -719,7 +706,6 @@ export type ConfiguredPlugin< TExtension extends object, TStore, TOptions extends object, - TSchema extends FumaTables | undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtensionService extends Context.Service | undefined = undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -727,16 +713,15 @@ export type ConfiguredPlugin< TGroup extends HttpApiGroup.Any = HttpApiGroup.Any, > = ( options?: TOptions & { - readonly storage?: (deps: StorageDeps) => TStore; + readonly storage?: (deps: StorageDeps) => TStore; }, -) => Plugin; +) => Plugin; // eslint-disable-next-line @typescript-eslint/ban-types export function definePlugin< TId extends string, TExtension extends object, TStore, - TSchema extends FumaTables | undefined = undefined, TOptions extends object = {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtensionService extends Context.Service | undefined = undefined, @@ -746,23 +731,14 @@ export function definePlugin< >( authorFactory: ( options?: TOptions, - ) => PluginSpec, -): ConfiguredPlugin< - TId, - TExtension, - TStore, - TOptions, - TSchema, - TExtensionService, - THandlersLayer, - TGroup -> { + ) => PluginSpec, +): ConfiguredPlugin { return (options) => { const { storage: storageOverride, ...rest }: { - storage?: (deps: StorageDeps) => TStore; + storage?: (deps: StorageDeps) => TStore; [key: string]: unknown; } = options ?? {}; diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index b4b2be6fa..5a587c4df 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Result } from "effect"; import { generateKeyBetween } from "fractional-indexing"; -import { scopedExecutorTable, type ToolPolicyRow } from "./core-schema"; +import { type ToolPolicyRow } from "./core-schema"; import { PolicyId, ScopeId } from "./ids"; import { Scope } from "./scope"; import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; @@ -268,13 +268,8 @@ const recordingHandler = (calls: { count: number }): ElicitationHandler => const decliningHandler: ElicitationHandler = () => Effect.succeed(ElicitationResponse.make({ action: "decline" })); -const policyTestSchema = { - ptest_marker: scopedExecutorTable("ptest_marker", {}), -}; - const policyTestPlugin = definePlugin(() => ({ id: "ptest" as const, - schema: policyTestSchema, storage: () => ({}), resolveAnnotations: ({ toolRows }) => { const out: Record = {}; diff --git a/packages/core/sdk/src/scope-policy.test.ts b/packages/core/sdk/src/scope-policy.test.ts index e5404fc55..49b56e6ad 100644 --- a/packages/core/sdk/src/scope-policy.test.ts +++ b/packages/core/sdk/src/scope-policy.test.ts @@ -5,7 +5,6 @@ import { column, idColumn, table } from "fumadb/schema"; import { collectTables, createExecutor } from "./executor"; import { StorageError } from "./fuma-runtime"; import { ScopeId } from "./ids"; -import { definePlugin } from "./plugin"; import { Scope } from "./scope"; import { dateColumn, scopedExecutorTable, textColumn } from "./core-schema"; import { @@ -13,7 +12,6 @@ import { executorScopePolicyName, type ExecutorScopePolicyContext, } from "./scope-policy"; -import { makeTestConfig } from "./test-config"; import { createSqliteTestFumaDb } from "./sqlite-test-db"; const scope = (id: string) => @@ -24,7 +22,6 @@ const scope = (id: string) => }); const innerScope = scope("inner"); -const outerScope = scope("outer"); const assertScopePolicyTypes = () => { const typedTable = scopedExecutorTable("typed_item", { @@ -53,76 +50,6 @@ const assertScopePolicyTypes = () => { void assertScopePolicyTypes; -const leakySchema = { - leaky_item: scopedExecutorTable("leaky_item", { - value: textColumn("value"), - }), -}; - -interface LeakyRow { - readonly id: string; - readonly scope_id: string; - readonly value: string; -} - -const leakyPlugin = definePlugin(() => ({ - id: "leaky" as const, - schema: leakySchema, - storage: ({ fuma }) => ({ - create: (row: LeakyRow) => fuma.use("leaky.create", (db) => db.create("leaky_item", row)), - readCoreTable: () => - fuma.use("leaky.readCoreTable", (db) => - db.findMany("secret" as keyof typeof leakySchema, {}), - ), - readInternal: () => - fuma.use("leaky.readInternal", async (db) => { - const internal = (db as { readonly internal?: unknown }).internal; - if (internal === undefined) return "hidden"; - return "visible"; - }), - rebindContext: () => - fuma.use("leaky.rebindContext", async (db) => { - const withContext = (db as { readonly withContext?: unknown }).withContext; - if (withContext === undefined) return "hidden"; - return "visible"; - }), - countAll: () => fuma.use("leaky.countAll", (db) => db.count("leaky_item")), - deleteAll: () => fuma.use("leaky.deleteAll", (db) => db.deleteMany("leaky_item", {})), - deleteAtScope: (scopeId: string) => - fuma.use("leaky.deleteAtScope", (db) => - db.deleteMany("leaky_item", { where: (b) => b("scope_id", "=", scopeId) }), - ), - moveAll: (scopeId: string) => - fuma.use("leaky.moveAll", (db) => - db.updateMany("leaky_item", { set: { scope_id: scopeId } }), - ), - moveAtScope: (targetScopeId: string, nextScopeId: string) => - fuma.use("leaky.moveAtScope", (db) => - db.updateMany("leaky_item", { - where: (b) => b("scope_id", "=", targetScopeId), - set: { scope_id: nextScopeId }, - }), - ), - renameAll: (value: string) => - fuma.use("leaky.renameAll", (db) => db.updateMany("leaky_item", { set: { value } })), - renameAtScope: (scopeId: string, value: string) => - fuma.use("leaky.renameAtScope", (db) => - db.updateMany("leaky_item", { - where: (b) => b("scope_id", "=", scopeId), - set: { value }, - }), - ), - readAll: () => - fuma.use("leaky.readAll", (db) => - db.findMany("leaky_item", { - select: ["id", "value"], - orderBy: ["id", "asc"], - }), - ), - }), - extension: (ctx) => ctx.storage, -}))(); - const unscopedSchema = { raw_table: table("raw_table", { row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), @@ -130,12 +57,6 @@ const unscopedSchema = { }), }; -const unscopedPlugin = definePlugin(() => ({ - id: "unscoped" as const, - schema: unscopedSchema, - storage: () => ({}), -}))(); - const incompletePolicySchema = { incomplete_policy_table: table("incomplete_policy_table", { row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), @@ -146,23 +67,7 @@ const incompletePolicySchema = { }), }; -const incompletePolicyPlugin = definePlugin(() => ({ - id: "incomplete-policy" as const, - schema: incompletePolicySchema, - storage: () => ({}), -}))(); - describe("executor FumaDB scope policy", () => { - it("rejects plugin tables without an explicit executor scope policy", () => { - expect(() => makeTestConfig({ plugins: [unscopedPlugin] as const })).toThrow(StorageError); - }); - - it("rejects plugin tables that only copy the executor policy name", () => { - expect(() => makeTestConfig({ plugins: [incompletePolicyPlugin] as const })).toThrow( - StorageError, - ); - }); - it.effect("rejects direct database handles with unscoped table maps", () => Effect.gen(function* () { const sqlite = yield* Effect.acquireRelease( @@ -191,13 +96,16 @@ describe("executor FumaDB scope policy", () => { }), ); - it.effect("rejects direct database handles that are missing plugin tables", () => + it.effect("rejects direct database handles that only copy the executor policy name", () => Effect.gen(function* () { const sqlite = yield* Effect.acquireRelease( Effect.promise(() => createSqliteTestFumaDb({ - tables: collectTables([]), - namespace: "executor_missing_table_test", + tables: { + ...collectTables([]), + ...incompletePolicySchema, + }, + namespace: "executor_incomplete_policy_test", }), ), (db) => Effect.promise(() => db.close()).pipe(Effect.ignore), @@ -205,233 +113,14 @@ describe("executor FumaDB scope policy", () => { const error = yield* createExecutor({ scopes: [innerScope], - plugins: [leakyPlugin] as const, db: sqlite.db, onElicitation: "accept-all", }).pipe(Effect.flip); expect(error).toBeInstanceOf(StorageError); expect(error).toMatchObject({ - message: expect.stringContaining("missing required table definitions"), - }); - }), - ); - - it.effect("allows in-scope partial reads and keeps hidden scope columns invisible", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [innerScope], - plugins: [leakyPlugin] as const, - }), - ); - - yield* executor.leaky.create({ - id: "visible", - scope_id: "inner", - value: "ok", - }); - - const rows = yield* executor.leaky.readAll(); - expect(rows).toEqual([{ id: "visible", value: "ok" }]); - expect("scope_id" in rows[0]!).toBe(false); - }), - ); - - it.effect("does not expose raw query internals or non-plugin tables to plugin storage", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [innerScope], - plugins: [leakyPlugin] as const, - }), - ); - - expect(yield* executor.leaky.readInternal()).toBe("hidden"); - expect(yield* executor.leaky.rebindContext()).toBe("hidden"); - - const error = yield* executor.leaky.readCoreTable().pipe(Effect.flip); - expect(error).toBeInstanceOf(StorageError); - expect(error).toMatchObject({ - message: expect.stringContaining("not available through this storage boundary"), - }); - }), - ); - - it.effect("scopes a buggy plugin read that forgets the scope predicate", () => - Effect.gen(function* () { - const config = makeTestConfig({ - scopes: [outerScope], - plugins: [leakyPlugin] as const, - }); - const outerExecutor = yield* createExecutor(config); - yield* outerExecutor.leaky.create({ - id: "outer-only", - scope_id: "outer", - value: "secret", - }); - - const innerExecutor = yield* createExecutor({ ...config, scopes: [innerScope] }); - const rows = yield* innerExecutor.leaky.readAll(); - - expect(rows).toEqual([]); - }), - ); - - it.effect("blocks out-of-scope writes before they reach the database", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [innerScope], - plugins: [leakyPlugin] as const, - }), - ); - - const error = yield* executor.leaky - .create({ - id: "bad-write", - scope_id: "outer", - value: "nope", - }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(StorageError); - expect(error).toMatchObject({ - message: expect.stringContaining("outside the executor scope stack"), - }); - }), - ); - - it.effect("requires updates to name the target scope", () => - Effect.gen(function* () { - const config = makeTestConfig({ - scopes: [outerScope], - plugins: [leakyPlugin] as const, - }); - const outerExecutor = yield* createExecutor(config); - yield* outerExecutor.leaky.create({ - id: "outer-row", - scope_id: "outer", - value: "secret", - }); - - const innerExecutor = yield* createExecutor({ ...config, scopes: [innerScope] }); - yield* innerExecutor.leaky.create({ - id: "inner-row", - scope_id: "inner", - value: "before", - }); - const error = yield* innerExecutor.leaky.renameAll("after").pipe(Effect.flip); - - expect(error).toBeInstanceOf(StorageError); - expect(error).toMatchObject({ - message: expect.stringContaining("must target an explicit scope"), - }); - yield* innerExecutor.leaky.renameAtScope("inner", "after"); - - expect(yield* innerExecutor.leaky.readAll()).toEqual([{ id: "inner-row", value: "after" }]); - expect(yield* outerExecutor.leaky.readAll()).toEqual([{ id: "outer-row", value: "secret" }]); - }), - ); - - it.effect("blocks update values that write rows out of the scope stack", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [innerScope], - plugins: [leakyPlugin] as const, - }), - ); - yield* executor.leaky.create({ - id: "inner-row", - scope_id: "inner", - value: "ok", - }); - - const error = yield* executor.leaky.moveAtScope("inner", "outer").pipe(Effect.flip); - expect(error).toBeInstanceOf(StorageError); - expect(error).toMatchObject({ - message: expect.stringContaining("outside the executor scope stack"), - }); - }), - ); - - it.effect("blocks update values that change the explicit target scope", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [innerScope, outerScope], - plugins: [leakyPlugin] as const, - }), - ); - yield* executor.leaky.create({ - id: "inner-row", - scope_id: "inner", - value: "ok", - }); - - const error = yield* executor.leaky.moveAtScope("inner", "outer").pipe(Effect.flip); - expect(error).toBeInstanceOf(StorageError); - expect(error).toMatchObject({ - message: expect.stringContaining("must write the same scope"), - }); - }), - ); - - it.effect("requires deletes to name the target scope", () => - Effect.gen(function* () { - const config = makeTestConfig({ - scopes: [outerScope], - plugins: [leakyPlugin] as const, - }); - const outerExecutor = yield* createExecutor(config); - yield* outerExecutor.leaky.create({ - id: "outer-row", - scope_id: "outer", - value: "secret", - }); - - const innerExecutor = yield* createExecutor({ ...config, scopes: [innerScope] }); - yield* innerExecutor.leaky.create({ - id: "inner-row", - scope_id: "inner", - value: "temporary", - }); - const error = yield* innerExecutor.leaky.deleteAll().pipe(Effect.flip); - - expect(error).toBeInstanceOf(StorageError); - expect(error).toMatchObject({ - message: expect.stringContaining("must target an explicit scope"), - }); - yield* innerExecutor.leaky.deleteAtScope("inner"); - - expect(yield* innerExecutor.leaky.readAll()).toEqual([]); - expect(yield* outerExecutor.leaky.readAll()).toEqual([{ id: "outer-row", value: "secret" }]); - }), - ); - - it.effect("scopes broad counts instead of counting rows outside the scope stack", () => - Effect.gen(function* () { - const config = makeTestConfig({ - scopes: [outerScope], - plugins: [leakyPlugin] as const, - }); - const outerExecutor = yield* createExecutor(config); - yield* outerExecutor.leaky.create({ - id: "outer-row", - scope_id: "outer", - value: "secret", - }); - - const innerExecutor = yield* createExecutor({ ...config, scopes: [innerScope] }); - yield* innerExecutor.leaky.create({ - id: "inner-row", - scope_id: "inner", - value: "visible", + message: expect.stringContaining("missing an executor scope policy"), }); - const count = yield* innerExecutor.leaky.countAll(); - - expect(count).toBe(1); }), ); }); diff --git a/packages/plugins/file-secrets/src/promise.ts b/packages/plugins/file-secrets/src/promise.ts index 27564608f..51a82eaa9 100644 --- a/packages/plugins/file-secrets/src/promise.ts +++ b/packages/plugins/file-secrets/src/promise.ts @@ -14,5 +14,5 @@ export type { FileSecretsPluginConfig } from "./index"; // doesn't re-export Plugin). export const fileSecretsPlugin = ( config?: FileSecretsPluginConfig, -): Plugin<"fileSecrets", FileSecretsExtension, Record, undefined> => +): Plugin<"fileSecrets", FileSecretsExtension, Record> => fileSecretsPluginEffect(config); diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index 16ba47c7c..a1b1dae80 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -10,9 +10,7 @@ export { type GraphqlSourceRef, } from "./plugin"; export { - graphqlSchema, makeDefaultGraphqlStore, - type GraphqlSchema, type GraphqlStore, type StoredGraphqlSource, type StoredOperation, diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 4d571ddb4..83ad197b1 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -48,7 +48,6 @@ import { import { invokeWithLayer } from "./invoke"; import { graphqlPresets } from "./presets"; import { - graphqlSchema, makeDefaultGraphqlStore, type GraphqlStore, type StoredGraphqlSource, @@ -1066,7 +1065,6 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { id: "graphql" as const, packageName: "@executor-js/plugin-graphql", sourcePresets: graphqlPresets, - schema: graphqlSchema, storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), extension: (ctx) => diff --git a/packages/plugins/graphql/src/sdk/store.ts b/packages/plugins/graphql/src/sdk/store.ts index 5b6d96dee..0f9faa9f5 100644 --- a/packages/plugins/graphql/src/sdk/store.ts +++ b/packages/plugins/graphql/src/sdk/store.ts @@ -2,7 +2,6 @@ import { Effect, Option, Predicate, Schema } from "effect"; import { ConfiguredCredentialBinding, - type FumaTables, type PluginStorageEntry, type StorageDeps, type StorageFailure, @@ -14,9 +13,6 @@ import { type ConfiguredGraphqlCredentialValue, } from "./types"; -export const graphqlSchema = {} satisfies FumaTables; -export type GraphqlSchema = typeof graphqlSchema; - export interface StoredGraphqlSource { readonly namespace: string; readonly scope: string; @@ -177,9 +173,7 @@ export interface GraphqlStore { readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } -export const makeDefaultGraphqlStore = ({ - pluginStorage, -}: StorageDeps): GraphqlStore => { +export const makeDefaultGraphqlStore = ({ pluginStorage }: StorageDeps): GraphqlStore => { const listOperationRowsForSourceScope = (sourceId: string, scope: string) => pluginStorage .list({ diff --git a/packages/plugins/keychain/src/promise.ts b/packages/plugins/keychain/src/promise.ts index 479ad796e..935386d6a 100644 --- a/packages/plugins/keychain/src/promise.ts +++ b/packages/plugins/keychain/src/promise.ts @@ -13,5 +13,4 @@ export type { KeychainPluginConfig } from "./index"; // root specifier (which doesn't re-export Plugin). export const keychainPlugin = ( config?: KeychainPluginConfig, -): Plugin<"keychain", KeychainExtension, Record, undefined> => - keychainPluginEffect(config); +): Plugin<"keychain", KeychainExtension, Record> => keychainPluginEffect(config); diff --git a/packages/plugins/mcp/src/sdk/binding-store.ts b/packages/plugins/mcp/src/sdk/binding-store.ts index adc6c40e1..004a065af 100644 --- a/packages/plugins/mcp/src/sdk/binding-store.ts +++ b/packages/plugins/mcp/src/sdk/binding-store.ts @@ -1,7 +1,6 @@ import { Effect, Option, Predicate, Schema } from "effect"; import { - type FumaTables, type PluginStorageEntry, type StorageDeps, type StorageFailure, @@ -9,9 +8,6 @@ import { import { McpStoredSourceData, McpToolBinding } from "./types"; -export const mcpSchema = {} satisfies FumaTables; -export type McpSchema = typeof mcpSchema; - const SOURCE_COLLECTION = "source"; const BINDING_COLLECTION = "binding"; @@ -131,7 +127,7 @@ const rowToBinding = ( }; }; -export const makeMcpStore = ({ pluginStorage }: StorageDeps): McpBindingStore => { +export const makeMcpStore = ({ pluginStorage }: StorageDeps): McpBindingStore => { const listBindingRowsForSourceScope = (namespace: string, scope: string) => pluginStorage .list({ diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index e397f095d..b764d5741 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -9,13 +9,7 @@ export { type McpConfigureSourceInput, } from "./plugin"; -export { - makeMcpStore, - mcpSchema, - type McpBindingStore, - type McpSchema, - type McpStoredSource, -} from "./binding-store"; +export { makeMcpStore, type McpBindingStore, type McpStoredSource } from "./binding-store"; export { ConfiguredMcpCredentialValue, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 22e830920..28048a0f7 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -41,12 +41,7 @@ import { type HttpConfiguredValueInput, } from "@executor-js/sdk/http-source"; -import { - makeMcpStore, - mcpSchema, - type McpBindingStore, - type McpStoredSource, -} from "./binding-store"; +import { makeMcpStore, type McpBindingStore, type McpStoredSource } from "./binding-store"; import { createMcpConnector, type ConnectorInput, type McpConnection } from "./connection"; import { discoverTools } from "./discover"; import { @@ -1303,7 +1298,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // so the server's `dangerouslyAllowStdioMCP` flag is the single // source of truth for both runtime and UI. clientConfig: { allowStdio }, - schema: mcpSchema, storage: (deps): McpBindingStore => makeMcpStore(deps), extension: (ctx) => { diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index c016f7734..798d3a414 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -17,8 +17,6 @@ export { type OpenApiSourceRef, } from "./plugin"; export { - openapiSchema, - type OpenapiSchema, type OpenapiStore, type StoredOperation, type StoredSource, diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 6a4d6e078..9913b6263 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -47,7 +47,6 @@ import { previewSpec, type SpecPreview } from "./preview"; import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, - openapiSchema, type OpenapiStore, type SourceConfig, type StoredOperation, @@ -1363,7 +1362,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { id: "openapi" as const, packageName: "@executor-js/plugin-openapi", sourcePresets: openApiPresets, - schema: openapiSchema, storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), extension: (ctx) => { diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index eb1d6e4eb..9accadc23 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -1,7 +1,6 @@ import { Effect, Option, Predicate, Schema } from "effect"; import { - type FumaTables, type PluginStorageEntry, type StorageDeps, type StorageFailure, @@ -23,9 +22,6 @@ export { queryParamBindingSlot, } from "./source-contracts"; -export const openapiSchema = {} satisfies FumaTables; -export type OpenapiSchema = typeof openapiSchema; - export interface SourceConfig { readonly spec: string; readonly sourceUrl?: string; @@ -228,9 +224,7 @@ export interface OpenapiStore { readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } -export const makeDefaultOpenapiStore = ({ - pluginStorage, -}: StorageDeps): OpenapiStore => { +export const makeDefaultOpenapiStore = ({ pluginStorage }: StorageDeps): OpenapiStore => { const sourceData = (source: StoredSource) => ({ namespace: source.namespace, scope: source.scope, diff --git a/packages/plugins/workos-vault/src/sdk/index.ts b/packages/plugins/workos-vault/src/sdk/index.ts index 9cb7082ed..078a6ecea 100644 --- a/packages/plugins/workos-vault/src/sdk/index.ts +++ b/packages/plugins/workos-vault/src/sdk/index.ts @@ -18,9 +18,7 @@ export { defaultWorkOSVaultContextForScope, makeWorkOSVaultSecretProvider, makeWorkosVaultStore, - workosVaultSchema, type WorkOSVaultContextForScope, type WorkOSVaultSecretProviderOptions, - type WorkosVaultSchema, type WorkosVaultStore, } from "./secret-store"; diff --git a/packages/plugins/workos-vault/src/sdk/plugin.ts b/packages/plugins/workos-vault/src/sdk/plugin.ts index 5722046b8..a113580e5 100644 --- a/packages/plugins/workos-vault/src/sdk/plugin.ts +++ b/packages/plugins/workos-vault/src/sdk/plugin.ts @@ -12,7 +12,6 @@ import { WORKOS_VAULT_PROVIDER_KEY, makeWorkOSVaultSecretProvider, makeWorkosVaultStore, - workosVaultSchema, type WorkOSVaultContextForScope, type WorkosVaultStore, } from "./secret-store"; @@ -67,7 +66,6 @@ const buildClient = ( export const workosVaultPlugin = definePlugin((options?: WorkOSVaultPluginOptions) => ({ id: "workosVault" as const, packageName: "@executor-js/plugin-workos-vault", - schema: workosVaultSchema, storage: (deps): WorkosVaultPluginStore => makeWorkosVaultStore(deps), extension: makeWorkOSVaultExtension, diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts index 2d6cb3637..e3ae7df90 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts @@ -21,16 +21,13 @@ import { } from "./client"; import { workosVaultPlugin } from "./plugin"; -interface VaultMetadataRow { - readonly id: string; +interface VaultMetadataStorageRow { + readonly key: string; readonly scope_id: string; - readonly name: string; - readonly purpose: string | null; - readonly created_at: Date; } -const toVaultMetadataRows = (rows: unknown): readonly VaultMetadataRow[] => - rows as readonly VaultMetadataRow[]; +const toVaultMetadataStorageRows = (rows: unknown): readonly VaultMetadataStorageRow[] => + rows as readonly VaultMetadataStorageRow[]; class FakeNotFoundError extends Error { readonly status = 404; @@ -409,10 +406,15 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => { }), ); - const rows = toVaultMetadataRows( + const rows = toVaultMetadataStorageRows( yield* Effect.promise(() => - config.db.findMany("workos_vault_metadata", { - where: (b) => b("id", "=", "api-token"), + config.db.findMany("plugin_storage", { + where: (b) => + b.and( + b("plugin_id", "=", "workosVault"), + b("collection", "=", "metadata"), + b("key", "=", "api-token"), + ), }), ), ); diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.ts b/packages/plugins/workos-vault/src/sdk/secret-store.ts index 03a04ac07..e7fec6cfe 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.ts @@ -1,16 +1,11 @@ -import { Effect } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { - dateColumn, - type FumaRow, - type FumaTables, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, StorageError, type SecretProvider, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; import { @@ -32,22 +27,53 @@ const MAX_KEK_NOT_READY_ATTEMPTS = 20; const KEK_NOT_READY_BACKOFF_MS = 1000; // --------------------------------------------------------------------------- -// Metadata schema — the plugin owns its own table for secret metadata -// (name, purpose, created_at). Values still live in WorkOS Vault; this -// table just tracks what we know about and lets us enumerate. +// Metadata storage — values live in WorkOS Vault; regular plugin storage +// tracks what we know about and lets us enumerate. // --------------------------------------------------------------------------- -export const workosVaultSchema = { - workos_vault_metadata: scopedExecutorTable("workos_vault_metadata", { - name: textColumn("name"), - purpose: nullableTextColumn("purpose"), - created_at: dateColumn("created_at"), - }), -} satisfies FumaTables; +const METADATA_COLLECTION = "metadata"; -export type WorkosVaultSchema = typeof workosVaultSchema; +const WorkosVaultMetadataData = Schema.Struct({ + name: Schema.String, + purpose: Schema.NullOr(Schema.String), + createdAt: Schema.DateFromString, +}); -type MetadataRow = FumaRow; +type WorkosVaultMetadataDataEncoded = typeof WorkosVaultMetadataData.Encoded; + +type MetadataRow = { + readonly id: string; + readonly scope_id: string; + readonly name: string; + readonly purpose: string | null; + readonly created_at: Date; +}; + +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const decodeMetadataData = Schema.decodeUnknownOption(WorkosVaultMetadataData); + +const coerceJson = (value: unknown): unknown => { + if (typeof value !== "string") return value; + return Option.getOrElse(decodeJson(value), () => value); +}; + +const metadataData = (row: MetadataRow): WorkosVaultMetadataDataEncoded => ({ + name: row.name, + purpose: row.purpose, + createdAt: row.created_at.toISOString(), +}); + +const entryToMetadataRow = (entry: PluginStorageEntry): MetadataRow | null => + Option.match(decodeMetadataData(coerceJson(entry.data)), { + onNone: () => null, + onSome: (data) => ({ + id: entry.key, + scope_id: String(entry.scopeId), + name: data.name, + purpose: data.purpose, + created_at: data.createdAt, + }), + }); // --------------------------------------------------------------------------- // WorkosVaultStore — typed metadata-store the plugin uses internally. @@ -60,76 +86,41 @@ export interface WorkosVaultStore { readonly list: () => Effect.Effect; } -export const makeWorkosVaultStore = (deps: StorageDeps): WorkosVaultStore => { - const { fuma } = deps; - const scopeIds = deps.scopes.map((scope) => String(scope.id)); +export const makeWorkosVaultStore = (deps: StorageDeps): WorkosVaultStore => { + const { pluginStorage } = deps; - // Every read/write to a specific row pins BOTH `id` and `scope_id`. - // Scope is a normal FumaDB predicate here, not hidden behavior. const findScoped = (id: string, scope: string) => - fuma - .use("workos_vault_metadata.findFirst", (db) => - db.findFirst("workos_vault_metadata", { - where: (b) => b.and(b("id", "=", id), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map((row): MetadataRow | null => row ?? null)); + pluginStorage + .getAtScope({ scope, collection: METADATA_COLLECTION, key: id }) + .pipe(Effect.map((entry): MetadataRow | null => (entry ? entryToMetadataRow(entry) : null))); return { get: (id, scope) => findScoped(id, scope), upsert: (row) => - Effect.gen(function* () { - const existing = yield* findScoped(row.id, row.scope_id); - if (existing) { - yield* fuma.use("workos_vault_metadata.updateMany", (db) => - db.updateMany("workos_vault_metadata", { - where: (b) => b.and(b("id", "=", row.id), b("scope_id", "=", row.scope_id)), - set: { - name: row.name, - purpose: row.purpose ?? null, - }, - }), - ); - return; - } - yield* fuma - .use("workos_vault_metadata.create", (db) => - db.create("workos_vault_metadata", { - id: row.id, - scope_id: row.scope_id, - name: row.name, - purpose: row.purpose ?? null, - created_at: row.created_at, - }), - ) - .pipe(Effect.asVoid); - }), + pluginStorage + .put({ + scope: row.scope_id, + collection: METADATA_COLLECTION, + key: row.id, + data: metadataData(row), + }) + .pipe(Effect.asVoid), remove: (id, scope) => Effect.gen(function* () { const existing = yield* findScoped(id, scope); if (!existing) return false; - yield* fuma.use("workos_vault_metadata.deleteMany", (db) => - db.deleteMany("workos_vault_metadata", { - where: (b) => b.and(b("id", "=", id), b("scope_id", "=", scope)), - }), - ); + yield* pluginStorage.remove({ scope, collection: METADATA_COLLECTION, key: id }); return true; }), list: () => - fuma - .use("workos_vault_metadata.findMany", (db) => - db.findMany("workos_vault_metadata", { - where: (b) => - scopeIds.length === 1 - ? b("scope_id", "=", scopeIds[0]!) - : b("scope_id", "in", [...scopeIds]), - }), - ) - .pipe( - Effect.map((rows): readonly MetadataRow[] => - [...rows].sort((l, r) => l.created_at.getTime() - r.created_at.getTime()), - ), + pluginStorage.list({ collection: METADATA_COLLECTION }).pipe( + Effect.map((rows): readonly MetadataRow[] => + rows + .map(entryToMetadataRow) + .filter(Predicate.isNotNull) + .sort((l, r) => l.created_at.getTime() - r.created_at.getTime()), ), + ), }; }; From d2dbf08470921400ab4a2afd2318a63cc155bf4d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 28 May 2026 23:43:05 -0700 Subject: [PATCH 2/2] Split WorkOS Vault migration statements --- apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql b/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql index 7570b5fb0..fa2f7df41 100644 --- a/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql +++ b/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql @@ -23,5 +23,6 @@ FROM "workos_vault_metadata" m ON CONFLICT ("scope_id", "id") DO UPDATE SET "data" = EXCLUDED."data", "updated_at" = EXCLUDED."updated_at"; +--> statement-breakpoint DROP TABLE IF EXISTS "workos_vault_metadata";