Extract users, teams, teamMembers, invitations tables and user-store into apps/cloud/src/services/ since they're cloud-only concerns. Add subpath export for schema (import from @executor/storage-postgres/schema) so the main entrypoint only exports functions and types.
Unified · Split
| @@ -1,5 +1,5 @@ | |||
| 1 | import { Context, Effect, Layer, Schema } from "effect"; | 1 | import { Context, Effect, Layer, Schema } from "effect"; |
| 2 | import { makeUserStore } from "@executor/storage-postgres"; | 2 | import { makeUserStore } from "../services/user-store"; |
| 3 | import type { DrizzleDb } from "../services/db"; | 3 | import type { DrizzleDb } from "../services/db"; |
| 4 | 4 | ||
| 5 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| @@ -3,9 +3,12 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { resolve } from "node:path"; | 5 | import { resolve } from "node:path"; |
| 6 | import * as schema from "@executor/storage-postgres"; | 6 | import * as sharedSchema from "@executor/storage-postgres/schema"; |
| 7 | import * as cloudSchema from "./schema"; | ||
| 7 | import type { DrizzleDb } from "@executor/storage-postgres"; | 8 | import type { DrizzleDb } from "@executor/storage-postgres"; |
| 8 | 9 | ||
| 10 | const schema = { ...sharedSchema, ...cloudSchema }; | ||
| 11 | |||
| 9 | export type { DrizzleDb }; | 12 | export type { DrizzleDb }; |
| 10 | 13 | ||
| 11 | const MIGRATIONS_DIR = resolve( | 14 | const MIGRATIONS_DIR = resolve( |
| @@ -0,0 +1,45 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Identity & multi-tenancy — cloud-specific tables | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | |||
| 5 | import { | ||
| 6 | pgTable, | ||
| 7 | text, | ||
| 8 | timestamp, | ||
| 9 | primaryKey, | ||
| 10 | } from "drizzle-orm/pg-core"; | ||
| 11 | |||
| 12 | export const users = pgTable("users", { | ||
| 13 | id: text("id").primaryKey(), | ||
| 14 | email: text("email").notNull().unique(), | ||
| 15 | name: text("name"), | ||
| 16 | avatarUrl: text("avatar_url"), | ||
| 17 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 18 | }); | ||
| 19 | |||
| 20 | export const teams = pgTable("teams", { | ||
| 21 | id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), | ||
| 22 | name: text("name").notNull(), | ||
| 23 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 24 | }); | ||
| 25 | |||
| 26 | export const teamMembers = pgTable( | ||
| 27 | "team_members", | ||
| 28 | { | ||
| 29 | teamId: text("team_id").notNull(), | ||
| 30 | userId: text("user_id").notNull(), | ||
| 31 | role: text("role").notNull().$default(() => "member"), | ||
| 32 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 33 | }, | ||
| 34 | (table) => [primaryKey({ columns: [table.teamId, table.userId] })], | ||
| 35 | ); | ||
| 36 | |||
| 37 | export const invitations = pgTable("invitations", { | ||
| 38 | id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), | ||
| 39 | teamId: text("team_id").notNull(), | ||
| 40 | email: text("email").notNull(), | ||
| 41 | invitedBy: text("invited_by").notNull(), | ||
| 42 | status: text("status").notNull().$default(() => "pending"), | ||
| 43 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 44 | expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), | ||
| 45 | }); | ||
| @@ -5,7 +5,7 @@ · 4 unmodified lines | |||
| 5 | import { eq, and } from "drizzle-orm"; | 5 | import { eq, and } from "drizzle-orm"; |
| 6 | 6 | ||
| 7 | import { users, teams, teamMembers, invitations } from "./schema"; | 7 | import { users, teams, teamMembers, invitations } from "./schema"; |
| 8 | import type { DrizzleDb } from "./types"; | 8 | import type { DrizzleDb } from "@executor/storage-postgres"; |
| 9 | 9 | ||
| 10 | export type User = typeof users.$inferSelect; | 10 | export type User = typeof users.$inferSelect; |
| 11 | export type Team = typeof teams.$inferSelect; | 11 | export type Team = typeof teams.$inferSelect; |
| @@ -4,7 +4,8 @@ · 3 unmodified lines | |||
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| 7 | ".": "./src/index.ts" | 7 | ".": "./src/index.ts", |
| 8 | "./schema": "./src/schema.ts" | ||
| 8 | }, | 9 | }, |
| 9 | "dependencies": { | 10 | "dependencies": { |
| 10 | "@effect/sql": "catalog:", | 11 | "@effect/sql": "catalog:", |
| @@ -21,7 +21,6 @@ import { · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | import { makePgConfig } from "./index"; | 22 | import { makePgConfig } from "./index"; |
| 23 | import { makePgKv } from "./pg-kv"; | 23 | import { makePgKv } from "./pg-kv"; |
| 24 | import { makeUserStore } from "./user-store"; | ||
| 25 | import * as schema from "./schema"; | 24 | import * as schema from "./schema"; |
| 26 | 25 | ||
| 27 | // --------------------------------------------------------------------------- | 26 | // --------------------------------------------------------------------------- |
| @@ -355,57 +354,3 @@ describe("Executor with Postgres storage", () => { · 327 unmodified lines | |||
| 355 | ); | 354 | ); |
| 356 | }); | 355 | }); |
| 357 | 356 | ||
| 358 | // --------------------------------------------------------------------------- | ||
| 359 | // User Store (not part of Executor, tested directly) | ||
| 360 | // --------------------------------------------------------------------------- | ||
| 361 | |||
| 362 | describe("UserStore", () => { | ||
| 363 | it("upsert and get user", async () => { | ||
| 364 | const store = makeUserStore(db); | ||
| 365 | const user = await store.upsertUser({ | ||
| 366 | id: "user-1", | ||
| 367 | email: "test@example.com", | ||
| 368 | name: "Test User", | ||
| 369 | }); | ||
| 370 | expect(user.id).toBe("user-1"); | ||
| 371 | |||
| 372 | const fetched = await store.getUser("user-1"); | ||
| 373 | expect(fetched!.name).toBe("Test User"); | ||
| 374 | |||
| 375 | await store.upsertUser({ id: "user-1", email: "test@example.com", name: "Updated" }); | ||
| 376 | expect((await store.getUser("user-1"))!.name).toBe("Updated"); | ||
| 377 | }); | ||
| 378 | |||
| 379 | it("create team and manage members", async () => { | ||
| 380 | const store = makeUserStore(db); | ||
| 381 | await store.upsertUser({ id: "u1", email: "a@example.com", name: "A" }); | ||
| 382 | await store.upsertUser({ id: "u2", email: "b@example.com", name: "B" }); | ||
| 383 | |||
| 384 | const team = await store.createTeam("My Team"); | ||
| 385 | await store.addMember(team.id, "u1", "owner"); | ||
| 386 | await store.addMember(team.id, "u2", "member"); | ||
| 387 | |||
| 388 | expect(await store.listMembers(team.id)).toHaveLength(2); | ||
| 389 | |||
| 390 | const teams = await store.getTeamsForUser("u1"); | ||
| 391 | expect(teams[0]!.teamName).toBe("My Team"); | ||
| 392 | |||
| 393 | await store.removeMember(team.id, "u2"); | ||
| 394 | expect(await store.listMembers(team.id)).toHaveLength(1); | ||
| 395 | }); | ||
| 396 | |||
| 397 | it("invitations workflow", async () => { | ||
| 398 | const store = makeUserStore(db); | ||
| 399 | await store.upsertUser({ id: "u1", email: "owner@example.com" }); | ||
| 400 | const team = await store.createTeam("Team"); | ||
| 401 | await store.addMember(team.id, "u1", "owner"); | ||
| 402 | |||
| 403 | const invitation = await store.createInvitation(team.id, "new@example.com", "u1"); | ||
| 404 | expect(invitation.status).toBe("pending"); | ||
| 405 | expect(await store.getPendingInvitations("new@example.com")).toHaveLength(1); | ||
| 406 | |||
| 407 | await store.acceptInvitation(invitation.id); | ||
| 408 | expect(await store.getPendingInvitations("new@example.com")).toHaveLength(0); | ||
| 409 | }); | ||
| 410 | |||
| 411 | }); | ||
| @@ -29,11 +29,8 @@ export { makePgKv } from "./pg-kv"; · 28 unmodified lines | |||
| 29 | export { makePgToolRegistry } from "./tool-registry"; | 29 | export { makePgToolRegistry } from "./tool-registry"; |
| 30 | export { makePgSecretStore } from "./secret-store"; | 30 | export { makePgSecretStore } from "./secret-store"; |
| 31 | export { makePgPolicyEngine } from "./policy-engine"; | 31 | export { makePgPolicyEngine } from "./policy-engine"; |
| 32 | export { makeUserStore } from "./user-store"; | ||
| 33 | export type { User, Team, TeamMember, Invitation } from "./user-store"; | ||
| 34 | export { encrypt, decrypt } from "./crypto"; | 32 | export { encrypt, decrypt } from "./crypto"; |
| 35 | export type { DrizzleDb } from "./types"; | 33 | export type { DrizzleDb } from "./types"; |
| 36 | export * from "./schema"; | ||
| 37 | 34 | ||
| 38 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 39 | // Convenience: build a full ExecutorConfig from a Drizzle DB instance | 36 | // Convenience: build a full ExecutorConfig from a Drizzle DB instance |
| @@ -19,45 +19,6 @@ const bytea = customType<{ data: Buffer }>({ · 18 unmodified lines | |||
| 19 | }, | 19 | }, |
| 20 | }); | 20 | }); |
| 21 | 21 | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | // Identity & multi-tenancy | ||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | |||
| 26 | export const users = pgTable("users", { | ||
| 27 | id: text("id").primaryKey(), | ||
| 28 | email: text("email").notNull().unique(), | ||
| 29 | name: text("name"), | ||
| 30 | avatarUrl: text("avatar_url"), | ||
| 31 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 32 | }); | ||
| 33 | |||
| 34 | export const teams = pgTable("teams", { | ||
| 35 | id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), | ||
| 36 | name: text("name").notNull(), | ||
| 37 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 38 | }); | ||
| 39 | |||
| 40 | export const teamMembers = pgTable( | ||
| 41 | "team_members", | ||
| 42 | { | ||
| 43 | teamId: text("team_id").notNull(), | ||
| 44 | userId: text("user_id").notNull(), | ||
| 45 | role: text("role").notNull().$default(() => "member"), | ||
| 46 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 47 | }, | ||
| 48 | (table) => [primaryKey({ columns: [table.teamId, table.userId] })], | ||
| 49 | ); | ||
| 50 | |||
| 51 | export const invitations = pgTable("invitations", { | ||
| 52 | id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), | ||
| 53 | teamId: text("team_id").notNull(), | ||
| 54 | email: text("email").notNull(), | ||
| 55 | invitedBy: text("invited_by").notNull(), | ||
| 56 | status: text("status").notNull().$default(() => "pending"), | ||
| 57 | createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), | ||
| 58 | expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), | ||
| 59 | }); | ||
| 60 | |||
| 61 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| 62 | // Domain data — all team-scoped | 23 | // Domain data — all team-scoped |
| 63 | // --------------------------------------------------------------------------- | 24 | // --------------------------------------------------------------------------- |