diff --git a/packages/notify/src/migrations.ts b/packages/notify/src/migrations.ts index 64b14d8618..eaffd1589a 100644 --- a/packages/notify/src/migrations.ts +++ b/packages/notify/src/migrations.ts @@ -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"; diff --git a/packages/notify/src/schema.ts b/packages/notify/src/schema.ts index b051d5d332..219fcaed0f 100644 --- a/packages/notify/src/schema.ts +++ b/packages/notify/src/schema.ts @@ -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"], diff --git a/packages/onboarding/src/migrations.ts b/packages/onboarding/src/migrations.ts index 99ebad80fc..a93b1c1377 100644 --- a/packages/onboarding/src/migrations.ts +++ b/packages/onboarding/src/migrations.ts @@ -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"; diff --git a/packages/onboarding/src/schema.ts b/packages/onboarding/src/schema.ts index 197ac76801..c1ceb2b9e8 100644 --- a/packages/onboarding/src/schema.ts +++ b/packages/onboarding/src/schema.ts @@ -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`, diff --git a/packages/webhook-triggers/src/migrations.ts b/packages/webhook-triggers/src/migrations.ts index ec2043c85a..4c6fcb75cc 100644 --- a/packages/webhook-triggers/src/migrations.ts +++ b/packages/webhook-triggers/src/migrations.ts @@ -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 diff --git a/packages/webhook-triggers/src/schema.ts b/packages/webhook-triggers/src/schema.ts index 4744ef685c..16cc88c94f 100644 --- a/packages/webhook-triggers/src/schema.ts +++ b/packages/webhook-triggers/src/schema.ts @@ -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(), /** @@ -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 }), }); @@ -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(), }, diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index 555600d3a9..1f55967206 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -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 @@ -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", @@ -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 diff --git a/scripts/checks/owned-tables-fk.ts b/scripts/checks/owned-tables-fk.ts new file mode 100644 index 0000000000..356dc2b32b --- /dev/null +++ b/scripts/checks/owned-tables-fk.ts @@ -0,0 +1,174 @@ +// check:owned-tables-fk — CL-8210's ruling: every custom (non-Interchange) +// Postgres table Workbench owns lives on its own named `pgSchema(...)`, +// never `public`, and every `tenant_id`/`principal_id` column on it is a +// real foreign key (a drizzle `.references(...)` call) back to +// Interchange's tenant/principal tables with `ON DELETE CASCADE`. This +// check enforces the mechanical half of that ruling over every +// `pgTable(...)`/`.table(...)` declaration outside `vendor/` — +// it cannot verify the FK target is actually Interchange's table, or that +// `onDelete: "cascade"` is set, only that `.references(` appears at all; +// a reviewer still reads the diff for those. +// +// A bare `pgTable("tenant", { id: text("id").primaryKey() })` — the +// "declared, never migrated" stub every FK-carrying package uses to name +// Interchange's own table for the FK to point at (see +// packages/notify/src/schema.ts's `hostTenant`/`hostPrincipal`) — is not a +// table this package owns, so it is exempted by HOST_STUB_TABLES below +// rather than by a generic "few columns" heuristic. +import { Glob } from "bun"; +import path from "node:path"; +import { emptyReport, reportAndExit, rootFromArgs, type CheckReport } from "./lib/repo"; + +const SCAN_DIRS = ["apps", "packages", "workflows"]; + +// Interchange's own control-plane tables, re-declared locally (never +// migrated) purely to carry a drizzle `.references()` FK target. These +// names are exempt from both rules: they're not owned tables. +const HOST_STUB_TABLES = new Set(["tenant", "principal"]); + +// Pre-existing tenant_id/principal_id debt this check does not (yet) fail +// on. `@corbits/chat` is owned by another workstream — its schema already +// lives on its own `chat` pgSchema (satisfying the schema half of +// CL-8210) but its tenant_id/principal_id columns predate the FK ruling +// and are out of scope for CL-8210's PR, which is explicitly barred from +// touching packages/chat/**. Tracked as its own follow-up rather than +// silently exempted forever: a shrinking ledger, not a blanket allowlist. +const FK_DEBT_FILES = new Set(["packages/chat/src/schema.ts"]); + +const TABLE_CALL_PATTERN = + /(?:(\b[A-Za-z_][A-Za-z0-9_]*)\.table|(\bpgTable))\(\s*"([a-zA-Z0-9_]+)"/g; + +export interface TableDeclaration { + readonly relPath: string; + readonly tableName: string; + readonly hasOwnSchema: boolean; + readonly body: string; +} + +/** Finds the matching closing brace for the `{` at `openIndex` in `text`. */ +function matchBrace(text: string, openIndex: number): number { + let depth = 0; + for (let i = openIndex; i < text.length; i++) { + if (text[i] === "{") depth++; + else if (text[i] === "}") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** Splits a `{...}` object body into its top-level `key: value` entries. */ +function splitTopLevelEntries(body: string): string[] { + const entries: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "{" || ch === "(" || ch === "[") depth++; + else if (ch === "}" || ch === ")" || ch === "]") depth--; + else if (ch === "," && depth === 0) { + entries.push(body.slice(start, i)); + start = i + 1; + } + } + entries.push(body.slice(start)); + return entries.map((e) => e.trim()).filter((e) => e.length > 0); +} + +export function findTableDeclarations(relPath: string, contents: string): TableDeclaration[] { + const declarations: TableDeclaration[] = []; + for (const match of contents.matchAll(TABLE_CALL_PATTERN)) { + const schemaVar = match[1]; + const tableName = match[3] ?? ""; + const callEnd = (match.index ?? 0) + match[0].length; + const braceStart = contents.indexOf("{", callEnd); + if (braceStart === -1) continue; + // A non-object second argument (e.g. a bare `pgTable("x", columns)` + // referencing a variable) has no literal body to inspect; skip it — + // nothing in this repo declares a table that way today. + const betweenCommaAndBrace = contents.slice(callEnd, braceStart).trim(); + if (betweenCommaAndBrace !== "," && betweenCommaAndBrace !== "") continue; + const braceEnd = matchBrace(contents, braceStart); + if (braceEnd === -1) continue; + declarations.push({ + relPath, + tableName, + hasOwnSchema: schemaVar !== undefined, + body: contents.slice(braceStart + 1, braceEnd), + }); + } + return declarations; +} + +const ID_COLUMN_PATTERN = /^(?:(\w+):\s*)?text\(\s*"(tenant_id|principal_id)"\s*\)/; + +export function auditOwnedTablesFk( + files: readonly { relPath: string; contents: string }[], +): CheckReport { + const report = emptyReport(); + for (const { relPath, contents } of files) { + if (FK_DEBT_FILES.has(relPath)) { + report.notes.push(`${relPath}: skipped — tracked FK debt, see FK_DEBT_FILES`); + continue; + } + const declarations = findTableDeclarations(relPath, contents); + for (const decl of declarations) { + if (HOST_STUB_TABLES.has(decl.tableName)) continue; + + if (!decl.hasOwnSchema) { + report.violations.push( + `${relPath}: table "${decl.tableName}" is declared with a bare pgTable(...) ` + + `call, not on a named pgSchema(...) — every table Workbench owns must live ` + + `on its own schema, never public (CL-8210).`, + ); + } + + for (const entry of splitTopLevelEntries(decl.body)) { + const idMatch = entry.match(ID_COLUMN_PATTERN); + if (idMatch === undefined || idMatch === null) continue; + if (!entry.includes(".references(")) { + report.violations.push( + `${relPath}: table "${decl.tableName}" column "${idMatch[2]}" has no ` + + `.references(...) — every tenant_id/principal_id column must be a real ` + + `foreign key to Interchange's tenant/principal table, ON DELETE CASCADE ` + + `(CL-8210).`, + ); + } + } + } + } + return report; +} + +async function scanFiles(root: string, dirs: readonly string[]): Promise { + const files: string[] = []; + for (const dir of dirs) { + const glob = new Glob(`${dir}/**/*.{ts,tsx}`); + for await (const file of glob.scan({ cwd: root, dot: false })) { + if (file.includes("node_modules/")) continue; + if (file.includes("/dist/") || file.startsWith("dist/")) continue; + if (file.includes("/vendor/") || file.startsWith("vendor/")) continue; + if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue; + files.push(file); + } + } + return files; +} + +async function main(): Promise { + const args = Bun.argv.slice(2); + const root = rootFromArgs(args); + const relPaths = await scanFiles(root, SCAN_DIRS); + const files = await Promise.all( + relPaths.map(async (relPath) => ({ + relPath, + contents: await Bun.file(path.join(root, relPath)).text(), + })), + ); + const report = auditOwnedTablesFk(files); + report.notes.push(`scanned ${files.length} file(s) under ${SCAN_DIRS.join(", ")}`); + reportAndExit("check:owned-tables-fk", report); +} + +if (import.meta.main) await main(); diff --git a/scripts/checks/test/no-product-tenancy.test.ts b/scripts/checks/test/no-product-tenancy.test.ts index ec2d379af0..cea7200f25 100644 --- a/scripts/checks/test/no-product-tenancy.test.ts +++ b/scripts/checks/test/no-product-tenancy.test.ts @@ -59,13 +59,19 @@ test("allowlisted product schema files pass at their max count", () => { { relPath: "packages/webhook-triggers/src/schema.ts", contents: [ + `const tenant = pgTable("tenant", {});`, + `const principal = pgTable("principal", {});`, `export const webhookTrigger = webhookTriggersSchema.table("webhook_trigger", {});`, `export const repoReviewLease = webhookTriggersSchema.table("repo_review_lease", {});`, ].join("\n"), }, { relPath: "packages/notify/src/schema.ts", - contents: `export const notifyDispatch = notifySchema.table("notify_dispatch", {});`, + contents: [ + `const tenant = pgTable("tenant", {});`, + `const principal = pgTable("principal", {});`, + `export const notifyDispatch = notifySchema.table("notify_dispatch", {});`, + ].join("\n"), }, ]); expect(report.violations).toEqual([]); @@ -77,6 +83,8 @@ test("allowlisted files fail when they grow past their max", () => { { relPath: "packages/webhook-triggers/src/schema.ts", contents: [ + `const tenant = pgTable("tenant", {});`, + `const principal = pgTable("principal", {});`, `export const webhookTrigger = webhookTriggersSchema.table("webhook_trigger", {});`, `export const repoReviewLease = webhookTriggersSchema.table("repo_review_lease", {});`, `export const extra = webhookTriggersSchema.table("webhook_extra", {});`, @@ -85,7 +93,7 @@ test("allowlisted files fail when they grow past their max", () => { ]); expect(report.violations).toHaveLength(1); expect(report.violations[0]).toContain("packages/webhook-triggers/src/schema.ts"); - expect(report.violations[0]).toContain("3 pgTable"); + expect(report.violations[0]).toContain("5 pgTable"); }); test("a `xyzSchema.table(...)` call is a violation naming the file, same as pgTable", () => { diff --git a/scripts/checks/test/owned-tables-fk.test.ts b/scripts/checks/test/owned-tables-fk.test.ts new file mode 100644 index 0000000000..5b42316ca6 --- /dev/null +++ b/scripts/checks/test/owned-tables-fk.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { auditOwnedTablesFk } from "../owned-tables-fk"; + +test("a schema-scoped table with FK'd tenant_id/principal_id passes clean", () => { + const report = auditOwnedTablesFk([ + { + relPath: "packages/notify/src/schema.ts", + contents: ` + 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(), + tenantId: text("tenant_id").notNull().references(() => hostTenant.id, { onDelete: "cascade" }), + principalId: text("principal_id").notNull().references(() => hostPrincipal.id, { onDelete: "cascade" }), + }); + `, + }, + ]); + expect(report.violations).toEqual([]); +}); + +test("a bare pgTable(...) call outside a named schema is a violation", () => { + const report = auditOwnedTablesFk([ + { + relPath: "packages/widget/src/schema.ts", + contents: ` + export const widget = pgTable("widget", { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull().references(() => hostTenant.id, { onDelete: "cascade" }), + }); + `, + }, + ]); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("pgTable"); + expect(report.violations[0]).toContain("widget"); +}); + +test("a tenant_id/principal_id column with no .references() is a violation", () => { + const report = auditOwnedTablesFk([ + { + relPath: "packages/widget/src/schema.ts", + contents: ` + export const widget = widgetSchema.table("widget", { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + principalId: text("principal_id").notNull(), + }); + `, + }, + ]); + expect(report.violations).toHaveLength(2); + expect(report.violations.some((v) => v.includes("tenant_id"))).toBe(true); + expect(report.violations.some((v) => v.includes("principal_id"))).toBe(true); +}); + +test("the tenant/principal host stub tables are exempt from both rules", () => { + const report = auditOwnedTablesFk([ + { + relPath: "packages/widget/src/schema.ts", + contents: ` + const hostTenant = pgTable("tenant", { id: text("id").primaryKey() }); + const hostPrincipal = pgTable("principal", { id: text("id").primaryKey() }); + `, + }, + ]); + expect(report.violations).toEqual([]); +}); + +test("packages/chat/src/schema.ts is skipped as tracked FK debt", () => { + const report = auditOwnedTablesFk([ + { + relPath: "packages/chat/src/schema.ts", + contents: ` + export const t = chatSchema.table("workbench_settings", { + tenantId: text("tenant_id").notNull(), + }); + `, + }, + ]); + expect(report.violations).toEqual([]); + expect(report.notes.some((n) => n.includes("packages/chat/src/schema.ts"))).toBe(true); +}); + +test("a column merely named tenant_id in an unrelated file with no table call is ignored", () => { + const report = auditOwnedTablesFk([ + { + relPath: "packages/widget/src/queries.ts", + contents: `const tenantId = row.tenant_id;`, + }, + ]); + expect(report.violations).toEqual([]); +}); diff --git a/scripts/checks/test/run.test.ts b/scripts/checks/test/run.test.ts index b04e2ea948..2744c531be 100644 --- a/scripts/checks/test/run.test.ts +++ b/scripts/checks/test/run.test.ts @@ -21,6 +21,7 @@ const EXPECTED_CHECK_NAMES = [ "killdates", "licenses", "no-product-tenancy", + "owned-tables-fk", "packages", "publishable-workflows", "react-ui-drift",