Skip to content

Commit a9c28af

Browse files
authored
@executor/storage-postgres: relational storage for cloud (#85)
- Drizzle schema: users, teams, team_members, invitations, sources, tools, secrets, policies, plugin_kv (no FKs for PlanetScale) - Implements ToolRegistryService, SecretStoreService, PolicyEngineService - AES-256-GCM encrypted secrets in DB - makePgKv for plugin compatibility via ScopedKv - User/team/invitation CRUD - 17 tests against PGlite (in-memory Postgres) - drizzle-orm in root catalog for single workspace copy
2 parents c2c5bb3 + 3e5de4a commit a9c28af

15 files changed

Lines changed: 136 additions & 127 deletions

‎.gitignore‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@ executor.har
3939
.executor/
4040

4141
# desktop app build artifacts
42-
apps/desktop/resources/
42+
apps/desktop/resources/
43+
44+
# cloud local dev database
45+
.pglite

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@effect/vitest": "^0.27.0",
4242
"@types/node": "^24.3.1",
4343
"bun-types": "^1.2.22",
44+
"drizzle-orm": "^0.44.0",
4445
"vitest": "^3.2.4"
4546
},
4647
"scripts": {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
CREATE TABLE "invitations" (
2+
"id" text PRIMARY KEY NOT NULL,
3+
"team_id" text NOT NULL,
4+
"email" text NOT NULL,
5+
"invited_by" text NOT NULL,
6+
"status" text NOT NULL,
7+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
8+
"expires_at" timestamp with time zone NOT NULL
9+
);
10+
--> statement-breakpoint
11+
CREATE TABLE "plugin_kv" (
12+
"team_id" text NOT NULL,
13+
"namespace" text NOT NULL,
14+
"key" text NOT NULL,
15+
"value" text NOT NULL,
16+
CONSTRAINT "plugin_kv_team_id_namespace_key_pk" PRIMARY KEY("team_id","namespace","key")
17+
);
18+
--> statement-breakpoint
19+
CREATE TABLE "policies" (
20+
"id" text NOT NULL,
21+
"team_id" text NOT NULL,
22+
"name" text NOT NULL,
23+
"action" text NOT NULL,
24+
"match_tool_pattern" text,
25+
"match_source_id" text,
26+
"priority" integer NOT NULL,
27+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
28+
CONSTRAINT "policies_id_team_id_pk" PRIMARY KEY("id","team_id")
29+
);
30+
--> statement-breakpoint
31+
CREATE TABLE "secrets" (
32+
"id" text NOT NULL,
33+
"team_id" text NOT NULL,
34+
"name" text NOT NULL,
35+
"purpose" text,
36+
"encrypted_value" "bytea" NOT NULL,
37+
"iv" "bytea" NOT NULL,
38+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
39+
CONSTRAINT "secrets_id_team_id_pk" PRIMARY KEY("id","team_id")
40+
);
41+
--> statement-breakpoint
42+
CREATE TABLE "sources" (
43+
"id" text NOT NULL,
44+
"team_id" text NOT NULL,
45+
"name" text NOT NULL,
46+
"kind" text NOT NULL,
47+
"config" jsonb NOT NULL,
48+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
49+
CONSTRAINT "sources_id_team_id_pk" PRIMARY KEY("id","team_id")
50+
);
51+
--> statement-breakpoint
52+
CREATE TABLE "team_members" (
53+
"team_id" text NOT NULL,
54+
"user_id" text NOT NULL,
55+
"role" text NOT NULL,
56+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
57+
CONSTRAINT "team_members_team_id_user_id_pk" PRIMARY KEY("team_id","user_id")
58+
);
59+
--> statement-breakpoint
60+
CREATE TABLE "teams" (
61+
"id" text PRIMARY KEY NOT NULL,
62+
"name" text NOT NULL,
63+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
64+
);
65+
--> statement-breakpoint
66+
CREATE TABLE "tool_definitions" (
67+
"name" text NOT NULL,
68+
"team_id" text NOT NULL,
69+
"schema" jsonb NOT NULL,
70+
CONSTRAINT "tool_definitions_name_team_id_pk" PRIMARY KEY("name","team_id")
71+
);
72+
--> statement-breakpoint
73+
CREATE TABLE "tools" (
74+
"id" text NOT NULL,
75+
"team_id" text NOT NULL,
76+
"source_id" text NOT NULL,
77+
"plugin_key" text NOT NULL,
78+
"name" text NOT NULL,
79+
"description" text,
80+
"may_elicit" boolean,
81+
"input_schema" jsonb,
82+
"output_schema" jsonb,
83+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
84+
CONSTRAINT "tools_id_team_id_pk" PRIMARY KEY("id","team_id")
85+
);
86+
--> statement-breakpoint
87+
CREATE TABLE "users" (
88+
"id" text PRIMARY KEY NOT NULL,
89+
"email" text NOT NULL,
90+
"name" text,
91+
"avatar_url" text,
92+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
93+
CONSTRAINT "users_email_unique" UNIQUE("email")
94+
);

‎packages/core/storage-postgres/drizzle/meta/0000_snapshot.json‎

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"id": "36e0d08d-3eb6-467a-8440-e39b7bfee61e",
2+
"id": "ee7b4779-1928-46a3-bcb8-1a48898bec19",
33
"prevId": "00000000-0000-0000-0000-000000000000",
44
"version": "7",
55
"dialect": "postgresql",
@@ -240,50 +240,6 @@
240240
"checkConstraints": {},
241241
"isRLSEnabled": false
242242
},
243-
"public.sessions": {
244-
"name": "sessions",
245-
"schema": "",
246-
"columns": {
247-
"id": {
248-
"name": "id",
249-
"type": "text",
250-
"primaryKey": true,
251-
"notNull": true
252-
},
253-
"user_id": {
254-
"name": "user_id",
255-
"type": "text",
256-
"primaryKey": false,
257-
"notNull": true
258-
},
259-
"team_id": {
260-
"name": "team_id",
261-
"type": "text",
262-
"primaryKey": false,
263-
"notNull": true
264-
},
265-
"expires_at": {
266-
"name": "expires_at",
267-
"type": "timestamp with time zone",
268-
"primaryKey": false,
269-
"notNull": true
270-
},
271-
"created_at": {
272-
"name": "created_at",
273-
"type": "timestamp with time zone",
274-
"primaryKey": false,
275-
"notNull": true,
276-
"default": "now()"
277-
}
278-
},
279-
"indexes": {},
280-
"foreignKeys": {},
281-
"compositePrimaryKeys": {},
282-
"uniqueConstraints": {},
283-
"policies": {},
284-
"checkConstraints": {},
285-
"isRLSEnabled": false
286-
},
287243
"public.sources": {
288244
"name": "sources",
289245
"schema": "",

‎packages/core/storage-postgres/drizzle/meta/_journal.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
{
66
"idx": 0,
77
"version": "7",
8-
"when": 1775542409153,
9-
"tag": "0000_great_speedball",
8+
"when": 1775552333040,
9+
"tag": "0000_spicy_zemo",
1010
"breakpoints": true
1111
}
1212
]

‎packages/core/storage-postgres/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@effect/sql": "catalog:",
1111
"@effect/sql-pg": "^0.28.0",
1212
"@executor/sdk": "workspace:*",
13-
"drizzle-orm": "^0.45.2",
13+
"drizzle-orm": "catalog:",
1414
"effect": "catalog:",
1515
"pg": "^8.16.0"
1616
},

‎packages/core/storage-postgres/src/index.test.ts‎

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ beforeAll(async () => {
4646

4747
beforeEach(async () => {
4848
await db.execute(
49-
sql`TRUNCATE plugin_kv, policies, secrets, tool_definitions, tools, sources, sessions, invitations, team_members, teams, users`,
49+
sql`TRUNCATE plugin_kv, policies, secrets, tool_definitions, tools, sources, invitations, team_members, teams, users`,
5050
);
5151
});
5252

@@ -408,24 +408,4 @@ describe("UserStore", () => {
408408
expect(await store.getPendingInvitations("new@example.com")).toHaveLength(0);
409409
});
410410

411-
it("session management", async () => {
412-
const store = makeUserStore(db);
413-
await store.upsertUser({ id: "u1", email: "a@example.com" });
414-
const team = await store.createTeam("Team");
415-
416-
const session = await store.createSession("u1", team.id, new Date(Date.now() + 3600_000));
417-
expect((await store.getSession(session.id))!.userId).toBe("u1");
418-
419-
await store.deleteSession(session.id);
420-
expect(await store.getSession(session.id)).toBeNull();
421-
});
422-
423-
it("expired sessions return null", async () => {
424-
const store = makeUserStore(db);
425-
await store.upsertUser({ id: "u1", email: "a@example.com" });
426-
const team = await store.createTeam("Team");
427-
428-
const session = await store.createSession("u1", team.id, new Date(Date.now() - 1000));
429-
expect(await store.getSession(session.id)).toBeNull();
430-
});
431411
});

‎packages/core/storage-postgres/src/index.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
//
1818
// ---------------------------------------------------------------------------
1919

20-
import type { PgDatabase } from "drizzle-orm/pg-core";
2120
import { ScopeId, makeInMemorySourceRegistry } from "@executor/sdk";
2221
import type { Scope, ExecutorConfig, ExecutorPlugin } from "@executor/sdk";
22+
import type { DrizzleDb } from "./types";
2323

2424
import { makePgToolRegistry } from "./tool-registry";
2525
import { makePgSecretStore } from "./secret-store";
@@ -30,8 +30,9 @@ export { makePgToolRegistry } from "./tool-registry";
3030
export { makePgSecretStore } from "./secret-store";
3131
export { makePgPolicyEngine } from "./policy-engine";
3232
export { makeUserStore } from "./user-store";
33-
export type { User, Team, TeamMember, Invitation, Session } from "./user-store";
33+
export type { User, Team, TeamMember, Invitation } from "./user-store";
3434
export { encrypt, decrypt } from "./crypto";
35+
export type { DrizzleDb } from "./types";
3536
export * from "./schema";
3637

3738
// ---------------------------------------------------------------------------
@@ -41,7 +42,7 @@ export * from "./schema";
4142
export const makePgConfig = <
4243
const TPlugins extends readonly ExecutorPlugin<string, object>[] = [],
4344
>(
44-
db: PgDatabase<any, any, any>,
45+
db: DrizzleDb,
4546
options: {
4647
readonly teamId: string;
4748
readonly teamName: string;

‎packages/core/storage-postgres/src/pg-kv.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
import { Effect } from "effect";
66
import { eq, and } from "drizzle-orm";
7-
import type { PgDatabase } from "drizzle-orm/pg-core";
87
import type { Kv } from "@executor/sdk";
98

109
import { pluginKv } from "./schema";
10+
import type { DrizzleDb } from "./types";
1111

12-
export const makePgKv = (db: PgDatabase<any, any, any>, teamId: string): Kv => ({
12+
export const makePgKv = (db: DrizzleDb, teamId: string): Kv => ({
1313
get: (namespace, key) =>
1414
Effect.tryPromise(async () => {
1515
const rows = await db

‎packages/core/storage-postgres/src/policy-engine.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
import { Effect } from "effect";
66
import { eq, and } from "drizzle-orm";
7-
import type { PgDatabase } from "drizzle-orm/pg-core";
87

98
import { Policy, PolicyId, ScopeId } from "@executor/sdk";
9+
import type { DrizzleDb } from "./types";
1010
import type { PolicyCheckInput } from "@executor/sdk";
1111

1212
import { policies } from "./schema";
1313

1414
export const makePgPolicyEngine = (
15-
db: PgDatabase<any, any, any>,
15+
db: DrizzleDb,
1616
teamId: string,
1717
) => {
1818
let counter = 0;

0 commit comments

Comments
 (0)