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
14 changes: 14 additions & 0 deletions packages/notify/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export const notifyMigrations: readonly NotifyMigration[] = [
ON "notify"."notify_dispatch" ("status", "next_attempt_at");
`,
},
{
// CL-8210: tenant_id/principal_id become real foreign keys into
// Interchange's own tenant/principal tables, ON DELETE CASCADE, so
// deleting a tenant or principal cleans up its dispatch rows instead
// of leaving them to dangle.
name: "0002_notify_dispatch_tenant_principal_fk",
sql: `
ALTER TABLE "notify"."notify_dispatch"
ADD CONSTRAINT "notify_dispatch_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant" ("id") ON DELETE CASCADE,
ADD CONSTRAINT "notify_dispatch_principal_id_fkey"
FOREIGN KEY ("principal_id") REFERENCES "public"."principal" ("id") ON DELETE CASCADE;
`,
},
];

const SCHEMA = "notify";
Expand Down
19 changes: 16 additions & 3 deletions packages/notify/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,30 @@
// whether a copy of that mail made it to one external place yet. Lives
// in its own `notify` Postgres schema, fully siloed from the platform's
// `public` schema — see docs/package-migrations.md.
import { index, integer, pgSchema, text, timestamp } from "drizzle-orm/pg-core";
//
// `tenant_id`/`principal_id` are hard foreign keys into Interchange's own
// `tenant`/`principal` tables (CL-8210): a dispatch row can never outlive
// the tenant or principal it was queued for. `hostTenant`/`hostPrincipal`
// below are declared, never migrated, just far enough to carry the FK —
// same pattern as `@corbits/artifacts`'s `src/schema.ts`.
import { index, integer, pgSchema, pgTable, text, timestamp } from "drizzle-orm/pg-core";

export const notifySchema = pgSchema("notify");

const hostTenant = pgTable("tenant", { id: text("id").primaryKey() });
const hostPrincipal = pgTable("principal", { id: text("id").primaryKey() });

export const notifyDispatch = notifySchema.table(
"notify_dispatch",
{
id: text("id").primaryKey(),
mailboxRowId: text("mailbox_row_id").notNull(),
tenantId: text("tenant_id").notNull(),
principalId: text("principal_id").notNull(),
tenantId: text("tenant_id")
.notNull()
.references(() => hostTenant.id, { onDelete: "cascade" }),
principalId: text("principal_id")
.notNull()
.references(() => hostPrincipal.id, { onDelete: "cascade" }),
sinkName: text("sink_name").notNull(),
status: text("status", {
enum: ["pending", "delivered", "failed", "dead"],
Expand Down
13 changes: 13 additions & 0 deletions packages/onboarding/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export const onboardingMigrations: readonly OnboardingMigration[] = [
);
`,
},
{
// CL-8210: tenant_id becomes a real foreign key into Interchange's own
// tenant table, ON DELETE CASCADE. user_id stays a plain text column —
// it is the workbench web app's better-auth user id, not an
// Interchange principal id, so there is no Interchange row to
// reference (see packages/onboarding/src/schema.ts).
name: "0002_pending_seed_tenant_fk",
sql: `
ALTER TABLE "onboarding"."pending_seed"
ADD CONSTRAINT "pending_seed_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant" ("id") ON DELETE CASCADE;
`,
},
];

const LEDGER_TABLE = "onboarding_migrations";
Expand Down
17 changes: 15 additions & 2 deletions packages/onboarding/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@
// own `onboarding` Postgres schema, never `public` — see
// docs/package-migrations.md. See `./pending-seed.ts` for what the row
// holds and why.
import { pgSchema, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
//
// `tenantId` is a hard foreign key into Interchange's own `tenant` table
// (CL-8210) — `hostTenant` below is declared, never migrated, just far
// enough to carry the FK, same pattern as `@corbits/artifacts`'s
// `src/schema.ts`. `userId` is deliberately NOT an FK: it is the
// workbench web app's own better-auth user id (see
// `packages/connections/src/oauth-routes.ts`'s `user.id`), not an
// Interchange principal id, so there is no Interchange row for it to
// reference.
import { pgSchema, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";

export const onboardingSchema = pgSchema("onboarding");

const hostTenant = pgTable("tenant", { id: text("id").primaryKey() });

export const pendingSeed = onboardingSchema.table(
"pending_seed",
{
userId: text("user_id").notNull(),
tenantId: text("tenant_id").notNull(),
tenantId: text("tenant_id")
.notNull()
.references(() => hostTenant.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
/**
* `CredentialCipher`-encrypted JSON (`principalId`, `tenantDomain`,
Expand Down
20 changes: 20 additions & 0 deletions packages/webhook-triggers/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ export const webhookTriggersMigrations: readonly WebhookTriggersMigration[] = [
ON "webhook_triggers"."repo_review_lease" ("tenant_id", "repo");
`,
},
{
// CL-8210: tenant_id (both tables) and webhook_trigger.created_by (a
// real Interchange principal id — see management-routes.ts's
// `createdBy: principal.id`) become hard foreign keys into
// Interchange's own tenant/principal tables, ON DELETE CASCADE, so
// deleting a tenant or principal cleans up the triggers and leases it
// owned.
name: "0005_tenant_principal_fk",
sql: `
ALTER TABLE "webhook_triggers"."webhook_trigger"
ADD CONSTRAINT "webhook_trigger_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant" ("id") ON DELETE CASCADE,
ADD CONSTRAINT "webhook_trigger_created_by_fkey"
FOREIGN KEY ("created_by") REFERENCES "public"."principal" ("id") ON DELETE CASCADE;

ALTER TABLE "webhook_triggers"."repo_review_lease"
ADD CONSTRAINT "repo_review_lease_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant" ("id") ON DELETE CASCADE;
`,
},
];

// Named distinctly from the platform's setup ledger and from any
Expand Down
32 changes: 23 additions & 9 deletions packages/webhook-triggers/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,33 @@
// connect card's start-reviewing step (CL-7242, `./repo-review-lease.ts`).
// Both live in this package's own `webhook_triggers` Postgres schema,
// fully siloed from the platform's `public` schema — see
// docs/package-migrations.md. `repoReviewLease` carries no foreign key
// to any Interchange core table: every workbench package already
// stores a platform id as a plain text column, never a cross-schema
// FK (see e.g. packages/chat/src/schema.ts's own header), and "repo"
// is a GitHub identity Interchange has no row for at all.
// docs/package-migrations.md.
//
// `tenant_id` (both tables) and `webhook_trigger.created_by` (a real
// Interchange principal id — see `management-routes.ts`'s
// `createdBy: principal.id`) are hard foreign keys into Interchange's
// own `tenant`/`principal` tables (CL-8210): `hostTenant`/`hostPrincipal`
// below are declared, never migrated, just far enough to carry the FK,
// same pattern as `@corbits/artifacts`'s `src/schema.ts`.
// `repo_review_lease` carries no FK beyond `tenant_id`: "repo" is a
// GitHub identity Interchange has no row for at all.
//
// The signing secret is encrypted at rest via Interchange's
// `CredentialCipher` seam — see `./store.ts` for the encrypt/decrypt
// wiring and `./signature.ts` for the security-model note on what that
// does and does not close.
import { boolean, pgSchema, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
import { boolean, pgSchema, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";

export const webhookTriggersSchema = pgSchema("webhook_triggers");

const hostTenant = pgTable("tenant", { id: text("id").primaryKey() });
const hostPrincipal = pgTable("principal", { id: text("id").primaryKey() });

export const webhookTrigger = webhookTriggersSchema.table("webhook_trigger", {
id: text("id").primaryKey(),
tenantId: text("tenant_id").notNull(),
tenantId: text("tenant_id")
.notNull()
.references(() => hostTenant.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workflowDefinitionId: text("workflow_definition_id").notNull(),
/**
Expand All @@ -39,7 +49,9 @@ export const webhookTrigger = webhookTriggersSchema.table("webhook_trigger", {
*/
secret: text("secret").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdBy: text("created_by").notNull(),
createdBy: text("created_by")
.notNull()
.references(() => hostPrincipal.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
});
Expand All @@ -62,7 +74,9 @@ export const repoReviewLease = webhookTriggersSchema.table(
"repo_review_lease",
{
id: text("id").primaryKey(),
tenantId: text("tenant_id").notNull(),
tenantId: text("tenant_id")
.notNull()
.references(() => hostTenant.id, { onDelete: "cascade" }),
repo: text("repo").notNull(),
leasedAt: timestamp("leased_at", { withTimezone: true }).notNull().defaultNow(),
},
Expand Down
28 changes: 23 additions & 5 deletions scripts/checks/no-product-tenancy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,14 @@ const ALLOWLIST: readonly {
},
{
relPath: "packages/webhook-triggers/src/schema.ts",
maxOccurrences: 2,
maxOccurrences: 4,
tables: [
// CL-8210: `tenant`/`principal` are Interchange's own tables,
// re-declared here (id column only, never migrated) just far
// enough to carry the FK from webhook_trigger/repo_review_lease —
// same pattern as @corbits/artifacts's src/schema.ts.
"tenant",
"principal",
"webhook_trigger",
// CL-7242: a short-lived lease serializing the GitHub connect
// card's former start-reviewing step (workbench-scoped mount
Expand All @@ -88,8 +94,15 @@ const ALLOWLIST: readonly {
},
{
relPath: "packages/notify/src/schema.ts",
maxOccurrences: 1,
tables: ["notify_dispatch"],
maxOccurrences: 3,
tables: [
// CL-8210: Interchange's own `tenant`/`principal`, re-declared
// (id column only, never migrated) to carry the FK from
// notify_dispatch — same pattern as @corbits/artifacts.
"tenant",
"principal",
"notify_dispatch",
],
},
{
relPath: "packages/bench/src/schema.ts",
Expand All @@ -109,8 +122,13 @@ const ALLOWLIST: readonly {
},
{
relPath: "packages/onboarding/src/schema.ts",
maxOccurrences: 1,
tables: ["pending_seed"],
maxOccurrences: 2,
tables: [
// CL-8210: Interchange's own `tenant`, re-declared (id column
// only, never migrated) to carry the FK from pending_seed.
"tenant",
"pending_seed",
],
},
{
// Eval-run history (CL-6143): one row per (eval, config) scored
Expand Down
Loading
Loading