Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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";
--> statement-breakpoint

DROP TABLE IF EXISTS "workos_vault_metadata";
7 changes: 7 additions & 0 deletions apps/cloud/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
11 changes: 0 additions & 11 deletions apps/cloud/src/services/executor-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions apps/local/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// ---------------------------------------------------------------------------
Expand Down
33 changes: 15 additions & 18 deletions apps/local/src/server/sqlite-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
});
Expand All @@ -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");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 1 addition & 2 deletions packages/core/sdk/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 22 additions & 46 deletions packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,43 +26,42 @@ 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(
Effect.gen(function* () {
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({
Expand All @@ -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 }),
),
);
}),
}),
}))();

Expand Down Expand Up @@ -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;
Expand Down
34 changes: 6 additions & 28 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,31 +430,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
}

// ---------------------------------------------------------------------------
// collectTables — merge core tables with every plugin's declared Fuma table.
// Hosts pass the result to FumaDB when constructing the database client.
// collectTables — return the executor-owned Fuma table set. Plugins persist
// through host-owned facades (`pluginStorage`, `blobs`) instead of contributing
// table definitions.
// ---------------------------------------------------------------------------

export const collectTables = (plugins: readonly AnyPlugin[]): FumaTables => {
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 => {
Expand Down Expand Up @@ -3039,18 +3022,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
});
}

const pluginFuma = makeFumaClient(
rootDb,
plugin.schema ? { tables: new Set(Object.keys(plugin.schema)) } : { tables: new Set() },
);
const pluginStorage = makePluginStorageFacade({
core,
pluginId: plugin.id,
scopeIds,
});
const storageDeps: StorageDeps = {
scopes,
fuma: pluginFuma,
// Blob keys are namespaced by `<scope>/<plugin>` 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
Expand Down
Loading
Loading