feat(db): hierarchy record class schema + witnesses (contract 1, M4-1a)
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Implements docs/requirements/hierarchy-schema.md sections 2-4 and the schema-layer half of section 6: - Five class tables (companies, estates, platform_projects, workspaces, hierarchy_grants) with the section 2.7 exhaustive column sets: child node tables carry no timestamps (renames are audited via events), no owner_id anywhere (section 4.4 - ownership is computed from grants). - Grant constraints per section 3: exactly-one-subject and exactly-one-target num_nonnulls CHECKs, six-column UNIQUE NULLS NOT DISTINCT, target FKs CASCADE / principal FKs RESTRICT, six btree indexes. - Migration 0018 generated by drizzle-kit; SQL verified against the contract text and applied on PGlite. - hierarchy-schema.witness.test.ts: dual-leg witness suite (PGlite always; real PostgreSQL under DATABASE_URL, the section 6.8 binding leg in CI). Covers parent-FK integrity + catalog assertion, slug scoping, column allowlist (6.2), all six grant subject/target forms, CHECK refusals, NULLS NOT DISTINCT duplicates, NOT NULL refusals, and deletion semantics (6.6): fail-closed parent delete, leaf cascade of exactly its grants, principal RESTRICT. - hierarchy-writer-coverage.test.ts: section 6.3(b) three-prong static assertion (alias-aware symbol writes, class-table names in SQL literals, raw-execution primitives) with empty writer allowlist, closed infrastructure register, and closed importer enumerations for the migration runner and migrate-tier. All prongs proven able to fire via a planted-violation control. Command family, audit events, and route inventory land in M4-1b.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
CREATE TABLE "companies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "companies_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "estates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
CONSTRAINT "estates_company_slug_uniq" UNIQUE("company_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hierarchy_grants" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text,
|
||||
"team_id" uuid,
|
||||
"company_id" uuid,
|
||||
"estate_id" uuid,
|
||||
"platform_project_id" uuid,
|
||||
"role" text NOT NULL,
|
||||
"granted_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hierarchy_grants_subject_target_role_uniq" UNIQUE NULLS NOT DISTINCT("user_id","team_id","company_id","estate_id","platform_project_id","role"),
|
||||
CONSTRAINT "hierarchy_grants_subject_check" CHECK (num_nonnulls(user_id, team_id) = 1),
|
||||
CONSTRAINT "hierarchy_grants_target_check" CHECK (num_nonnulls(company_id, estate_id, platform_project_id) = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "platform_projects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"estate_id" uuid NOT NULL,
|
||||
CONSTRAINT "platform_projects_estate_slug_uniq" UNIQUE("estate_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"platform_project_id" uuid NOT NULL,
|
||||
CONSTRAINT "workspaces_platform_project_slug_uniq" UNIQUE("platform_project_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "estates" ADD CONSTRAINT "estates_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_estate_id_estates_id_fk" FOREIGN KEY ("estate_id") REFERENCES "public"."estates"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_platform_project_id_platform_projects_id_fk" FOREIGN KEY ("platform_project_id") REFERENCES "public"."platform_projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "platform_projects" ADD CONSTRAINT "platform_projects_estate_id_estates_id_fk" FOREIGN KEY ("estate_id") REFERENCES "public"."estates"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_platform_project_id_platform_projects_id_fk" FOREIGN KEY ("platform_project_id") REFERENCES "public"."platform_projects"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_company_id_idx" ON "hierarchy_grants" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_estate_id_idx" ON "hierarchy_grants" USING btree ("estate_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_platform_project_id_idx" ON "hierarchy_grants" USING btree ("platform_project_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_user_id_idx" ON "hierarchy_grants" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_team_id_idx" ON "hierarchy_grants" USING btree ("team_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_granted_by_idx" ON "hierarchy_grants" USING btree ("granted_by");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
||||
"when": 1787609223282,
|
||||
"tag": "0017_accounts_issuer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1787862158838,
|
||||
"tag": "0018_clean_cobalt_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Hierarchy schema witnesses — contract 1 (docs/requirements/hierarchy-schema.md) §6.
|
||||
*
|
||||
* Witnesses §6.1 (chain construction, slug scoping, grant CHECKs, grant
|
||||
* uniqueness, NOT NULLs), §6.2 (column allowlist), the database-level parts of
|
||||
* §6.6 (RESTRICT/cascade deletion behavior), and §6.7's catalog half (no
|
||||
* foreign keys from outside the class into class tables).
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs, so the witnesses execute locally
|
||||
* with no database configured.
|
||||
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set — in CI that is
|
||||
* the ci-postgres service, migrated by the pipeline before `pnpm test`.
|
||||
* This leg is the contract's binding witness; the PGlite leg is the local
|
||||
* development signal.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import {
|
||||
companies,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
teams,
|
||||
users,
|
||||
} from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
delete: (t: unknown) => { where?: unknown } & PromiseLike<unknown>;
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
|
||||
const COLUMN_ALLOWLIST: Record<string, string[]> = {
|
||||
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
|
||||
estates: ['id', 'name', 'slug', 'company_id'],
|
||||
platform_projects: ['id', 'name', 'slug', 'estate_id'],
|
||||
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
|
||||
hierarchy_grants: [
|
||||
'id',
|
||||
'user_id',
|
||||
'team_id',
|
||||
'company_id',
|
||||
'estate_id',
|
||||
'platform_project_id',
|
||||
'role',
|
||||
'granted_by',
|
||||
'created_at',
|
||||
],
|
||||
};
|
||||
|
||||
const NODE_TABLES = ['companies', 'estates', 'platform_projects', 'workspaces'];
|
||||
const CLASS_TABLES = [...NODE_TABLES, 'hierarchy_grants'];
|
||||
|
||||
/**
|
||||
* Drizzle wraps constraint failures ("Failed query: ...") with the driver
|
||||
* error attached as `cause`. Match the pattern anywhere along the cause chain.
|
||||
*/
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `hier-w-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
const userA = `${T}-user-a`;
|
||||
const userB = `${T}-user-b`;
|
||||
let teamId: string;
|
||||
let companyId: string;
|
||||
let company2Id: string;
|
||||
let estateId: string;
|
||||
let estate2Id: string;
|
||||
let ppId: string;
|
||||
let workspaceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await db()
|
||||
.insert(users)
|
||||
.values([
|
||||
{ id: userA, name: 'Witness A', email: `${userA}@example.com` },
|
||||
{ id: userB, name: 'Witness B', email: `${userB}@example.com` },
|
||||
]);
|
||||
teamId = randomUUID();
|
||||
await db()
|
||||
.insert(teams)
|
||||
.values({
|
||||
id: teamId,
|
||||
name: `${T}-team`,
|
||||
slug: `${T}-team`,
|
||||
ownerId: userA,
|
||||
managerId: userA,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Bottom-up, fail-closed order; grants cascade with their targets.
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM workspaces WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM platform_projects WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM estates WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM teams WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM users WHERE id LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── §6.1 chain construction ────────────────────────────────────────────────
|
||||
|
||||
it('accepts a full valid chain: company → estate → platform-project → workspace', async () => {
|
||||
companyId = randomUUID();
|
||||
estateId = randomUUID();
|
||||
ppId = randomUUID();
|
||||
workspaceId = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: companyId, name: 'Acme', slug: `${T}-acme` });
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Estate 1', slug: `${T}-e1`, companyId });
|
||||
await db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: ppId, name: 'PP 1', slug: `${T}-pp1`, estateId });
|
||||
await db()
|
||||
.insert(workspaces)
|
||||
.values({ id: workspaceId, name: 'WS 1', slug: `${T}-ws1`, platformProjectId: ppId });
|
||||
});
|
||||
|
||||
it('accepts two siblings under one parent (the §2.5 control)', async () => {
|
||||
estate2Id = randomUUID();
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estate2Id, name: 'Estate 2', slug: `${T}-e2`, companyId });
|
||||
});
|
||||
|
||||
it('refuses inserts with a null parent FK', async () => {
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO estates (id, name, slug, company_id) VALUES (${randomUUID()}, 'x', ${T + '-null-e'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO platform_projects (id, name, slug, estate_id) VALUES (${randomUUID()}, 'x', ${T + '-null-p'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO workspaces (id, name, slug, platform_project_id) VALUES (${randomUUID()}, 'x', ${T + '-null-w'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses inserts with a dangling parent FK', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'x', slug: `${T}-dangle`, companyId: randomUUID() }),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('catalog: each child table has exactly one parent-FK column and no parentage edge table exists', async () => {
|
||||
const res = rows(
|
||||
await db().execute(sql`
|
||||
SELECT tc.table_name, kcu.column_name, ccu.table_name AS ref_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
||||
`),
|
||||
);
|
||||
const nodeSet = new Set(NODE_TABLES);
|
||||
// Exactly one parent FK per child node table.
|
||||
for (const [child, parent] of [
|
||||
['estates', 'companies'],
|
||||
['platform_projects', 'estates'],
|
||||
['workspaces', 'platform_projects'],
|
||||
] as const) {
|
||||
const parentFks = res.filter(
|
||||
(r) => r['table_name'] === child && nodeSet.has(String(r['ref_table'])),
|
||||
);
|
||||
expect(parentFks.map((r) => `${r['column_name']}->${r['ref_table']}`)).toEqual([
|
||||
`${{ estates: 'company_id', platform_projects: 'estate_id', workspaces: 'platform_project_id' }[child]}->${parent}`,
|
||||
]);
|
||||
}
|
||||
// No table outside the class references a node table (also §6.7's catalog
|
||||
// half for companies/estates/platform_projects/workspaces), and the only
|
||||
// multi-FK referencer is hierarchy_grants (grant attachment, not
|
||||
// parentage).
|
||||
const referencers = new Map<string, number>();
|
||||
for (const r of res) {
|
||||
if (nodeSet.has(String(r['ref_table']))) {
|
||||
const t = String(r['table_name']);
|
||||
referencers.set(t, (referencers.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const [table, count] of referencers) {
|
||||
expect(CLASS_TABLES, `unexpected referencer of a node table: ${table}`).toContain(table);
|
||||
if (count > 1) expect(table).toBe('hierarchy_grants');
|
||||
}
|
||||
// No FK anywhere references hierarchy_grants.
|
||||
expect(res.filter((r) => r['ref_table'] === 'hierarchy_grants')).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.1 slug scoping ──────────────────────────────────────────────────────
|
||||
|
||||
it('refuses a duplicate slug under the same parent, accepts it under another parent', async () => {
|
||||
company2Id = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: company2Id, name: 'Beta', slug: `${T}-beta` });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-e1`, companyId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
// Same slug, different company — accepted.
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'ok', slug: `${T}-e1`, companyId: company2Id });
|
||||
// companies.slug is unique per deployment.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(companies)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-acme` }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.2 column allowlist ──────────────────────────────────────────────────
|
||||
|
||||
it('column allowlist: each class table has exactly its declared columns (no payload, no owner_id)', async () => {
|
||||
for (const [table, allow] of Object.entries(COLUMN_ALLOWLIST)) {
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${table}`,
|
||||
),
|
||||
);
|
||||
const actual = res.map((r) => String(r['column_name'])).sort();
|
||||
expect(actual, `column set of ${table}`).toEqual([...allow].sort());
|
||||
}
|
||||
});
|
||||
|
||||
// ── §6.1 grant CHECKs ──────────────────────────────────────────────────────
|
||||
|
||||
it('accepts one valid grant per subject×target form', async () => {
|
||||
// All six forms; also the base rows for the §6.1 uniqueness witness below.
|
||||
const forms = [
|
||||
{ userId: userA, companyId },
|
||||
{ userId: userA, estateId },
|
||||
{ userId: userA, platformProjectId: ppId },
|
||||
{ teamId, companyId },
|
||||
{ teamId, estateId },
|
||||
{ teamId, platformProjectId: ppId },
|
||||
];
|
||||
for (const form of forms) {
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ ...form, role: 'owner', grantedBy: userA });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a grant with zero or two subjects (exactly-one-of CHECK)', async () => {
|
||||
await expectViolation(
|
||||
db().insert(hierarchyGrants).values({ companyId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, teamId, companyId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a grant with zero or two targets (exactly-one-of CHECK)', async () => {
|
||||
await expectViolation(
|
||||
db().insert(hierarchyGrants).values({ userId: userA, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, estateId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.1 grant uniqueness (NULLS NOT DISTINCT) ─────────────────────────────
|
||||
|
||||
it('refuses a duplicate (subject, target, role) for each of the six forms', async () => {
|
||||
const forms = [
|
||||
{ userId: userA, companyId },
|
||||
{ userId: userA, estateId },
|
||||
{ userId: userA, platformProjectId: ppId },
|
||||
{ teamId, companyId },
|
||||
{ teamId, estateId },
|
||||
{ teamId, platformProjectId: ppId },
|
||||
];
|
||||
for (const form of forms) {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ ...form, role: 'owner', grantedBy: userB }),
|
||||
/duplicate key|unique/i,
|
||||
`duplicate form ${JSON.stringify(form)} must be refused`,
|
||||
);
|
||||
}
|
||||
// Control: same subject and target with a different role is a new grant.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
|
||||
});
|
||||
|
||||
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
|
||||
|
||||
it('refuses null role, granted_by, and null name/slug columns', async () => {
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, NULL, ${userA})`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`INSERT INTO companies (name, slug) VALUES (NULL, ${T + '-nn'})`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`INSERT INTO companies (name, slug) VALUES ('x', NULL)`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO estates (name, slug, company_id) VALUES ('x', NULL, ${companyId})`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.6 deletion (database-level witnesses) ───────────────────────────────
|
||||
|
||||
it('refuses deleting a node with children (fail-closed bottom-up)', async () => {
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM estates WHERE id = ${estateId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM platform_projects WHERE id = ${ppId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('cascades a deleted leaf node’s grants and nothing else', async () => {
|
||||
// estate2 is a leaf (no platform-projects). Attach one grant to it.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, estateId: estate2Id, role: 'viewer', grantedBy: userA });
|
||||
const grantCount = async () =>
|
||||
Number(
|
||||
rows(
|
||||
await db().execute(
|
||||
sql`SELECT count(*)::int AS n FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`,
|
||||
),
|
||||
)[0]!['n'],
|
||||
);
|
||||
const before = await grantCount();
|
||||
await db().execute(sql`DELETE FROM estates WHERE id = ${estate2Id}`);
|
||||
// Exactly the one grant on the deleted estate is gone.
|
||||
expect(await grantCount()).toBe(before - 1);
|
||||
});
|
||||
|
||||
it('refuses deleting a user or team that is a grant subject or granted_by referent (RESTRICT)', async () => {
|
||||
await expectViolation(db().execute(sql`DELETE FROM users WHERE id = ${userA}`), /foreign key/i);
|
||||
// userB is only a subject (its estate2 grant cascaded away above, but it
|
||||
// still holds no grants — re-create one to witness subject RESTRICT).
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId: company2Id, role: 'viewer', grantedBy: userA });
|
||||
await expectViolation(db().execute(sql`DELETE FROM users WHERE id = ${userB}`), /foreign key/i);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM teams WHERE id = ${teamId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('hierarchy schema witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'hier-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('hierarchy schema witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Hierarchy writer-coverage assertion — contract 1
|
||||
* (docs/requirements/hierarchy-schema.md) §6.3(b).
|
||||
*
|
||||
* A static CI assertion over the Gateway and package production sources with
|
||||
* three prongs, each bound to a closed, explicitly enumerated allowlist:
|
||||
*
|
||||
* (i) Symbol prong — write references (insert/update/delete) to the
|
||||
* class-table schema symbols occur only in allowlisted modules.
|
||||
* Import aliasing is followed: `import { companies as c }` makes `c`
|
||||
* a class-table symbol in that file.
|
||||
* (ii) Literal prong — a class-table name inside a SQL string or tagged
|
||||
* SQL template outside the allowlist fails. Schema definitions and
|
||||
* generated migrations are excluded from this prong (per contract).
|
||||
* (iii) Raw-execution prong — raw-SQL execution primitives (the ORM's
|
||||
* raw/unsafe constructors, driver-level clients) outside the writer
|
||||
* allowlist and the infrastructure register fail, regardless of SQL
|
||||
* content. Direct database-driver imports count as raw-execution
|
||||
* capability: they are what makes dynamically assembled SQL
|
||||
* executable, and the import is statically detectable even when the
|
||||
* SQL string is not.
|
||||
*
|
||||
* Runtime code-construction primitives (eval, new Function) and non-literal
|
||||
* dynamic imports fail anywhere — allowlist and register included.
|
||||
*
|
||||
* The writer allowlist names hierarchy command/repository modules ONLY. It is
|
||||
* empty today: the hierarchy command family (M4-1b) has not landed, so no
|
||||
* production module may write the class tables. The infrastructure register
|
||||
* holds legitimate non-hierarchy raw execution (migration runner, storage
|
||||
* adapters, health probes); registered modules are exempt from prong (iii)
|
||||
* only — prongs (i) and (ii) apply to them with no exemption, and no
|
||||
* registered module may appear on the writer allowlist. Neither list may take
|
||||
* a generic raw-SQL helper, and an allowlisted module must not export a
|
||||
* function that executes caller-supplied SQL (review-enforced, §5.1).
|
||||
*
|
||||
* A false positive is resolved in the same PR by adding the module to the one
|
||||
* enumerated list its role permits — never by weakening the assertion.
|
||||
*
|
||||
* Test files (*.spec.*, *.test.*, __tests__/) are not scanned: they are not
|
||||
* production mutation paths, and the contract's own §6 witnesses must write
|
||||
* class tables directly to witness database constraints.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const REPO_ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..');
|
||||
|
||||
/** Drizzle schema symbols of the five class tables (packages/db/src/schema.ts). */
|
||||
const CLASS_SYMBOLS = ['companies', 'estates', 'platformProjects', 'workspaces', 'hierarchyGrants'];
|
||||
|
||||
/** SQL table names of the five class tables. */
|
||||
const CLASS_TABLES = [
|
||||
'companies',
|
||||
'estates',
|
||||
'platform_projects',
|
||||
'workspaces',
|
||||
'hierarchy_grants',
|
||||
];
|
||||
|
||||
/**
|
||||
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
|
||||
* EMPTY until the hierarchy command family lands (M4-1b). Adding a module
|
||||
* here is a contract-conformance decision reviewed under §5.1 — the module
|
||||
* must be part of the Gateway hierarchy command path.
|
||||
*/
|
||||
const WRITER_ALLOWLIST: string[] = [];
|
||||
|
||||
/**
|
||||
* Infrastructure register: closed enumeration of legitimate non-hierarchy raw
|
||||
* execution. Exempt from prong (iii) ONLY; prongs (i)/(ii) still apply, and
|
||||
* none of these may ever join the writer allowlist.
|
||||
*/
|
||||
const INFRA_REGISTER: string[] = [
|
||||
'packages/db/src/client.ts', // connection factory (imports postgres driver)
|
||||
'packages/db/src/client-pglite.ts', // PGlite factory (imports the pglite driver)
|
||||
'packages/db/src/migrate.ts', // migration runner (hash-ledger DDL execution)
|
||||
'packages/memory/src/insights.ts', // analytics raw query over memory tables
|
||||
'packages/storage/src/tier-detection.ts', // driver import for tier probing
|
||||
'packages/storage/src/adapters/pglite.ts', // storage adapter (driver-level query)
|
||||
'packages/storage/src/adapters/postgres.ts', // storage adapter (extension bootstrap)
|
||||
'packages/storage/src/migrate-tier.ts', // storage tier migration
|
||||
'packages/storage/src/cli.ts', // storage CLI health probe
|
||||
'apps/gateway/src/admin/admin-health.controller.ts', // SELECT 1 health probe
|
||||
];
|
||||
|
||||
/**
|
||||
* Closed importer enumerations for registered modules that EXPORT
|
||||
* SQL-executing functions (the laundering path §6.3b closes). Import edges
|
||||
* are checked re-export-aware — the db package barrel and literal dynamic
|
||||
* `import('@mosaicstack/db')` are edges like any static import. The measured
|
||||
* production importer set of the migration runner (contract 1 revision 9):
|
||||
* the Gateway database module, the storage Postgres adapter, and two mosaic
|
||||
* CLI commands routed through literal dynamic imports. The gateway
|
||||
* schema-check module receives the runner's functions by parameter injection
|
||||
* and has no import edge, so it is not enumerated. Being enumerated confers
|
||||
* nothing else: importers stay subject to prongs (i)/(ii) and gain no writer
|
||||
* standing.
|
||||
*/
|
||||
const MIGRATION_RUNNER_SYMBOLS = ['runMigrations', 'runPgliteMigrations', 'getMigrationStatus'];
|
||||
const MIGRATION_RUNNER_IMPORTERS: string[] = [
|
||||
'apps/gateway/src/database/database.module.ts',
|
||||
'packages/storage/src/adapters/postgres.ts',
|
||||
'packages/mosaic/src/commands/fleet-backlog.ts',
|
||||
'packages/mosaic/src/commands/gateway/verify.ts',
|
||||
];
|
||||
const MIGRATE_TIER_SYMBOLS = [
|
||||
'runMigrateTier',
|
||||
'checkTargetPreconditions',
|
||||
'PostgresMigrationTarget',
|
||||
'DrizzleMigrationSource',
|
||||
];
|
||||
const MIGRATE_TIER_IMPORTERS: string[] = [
|
||||
'packages/storage/src/cli.ts', // storage CLI command surface
|
||||
'packages/storage/src/index.ts', // package barrel re-export (public API)
|
||||
];
|
||||
|
||||
const SCAN_ROOTS = ['apps', 'packages'];
|
||||
const EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
||||
|
||||
function isTestPath(rel: string): boolean {
|
||||
return (
|
||||
/\.(spec|test)\.[cm]?tsx?$/.test(rel) ||
|
||||
rel.split(sep).includes('__tests__') ||
|
||||
rel.endsWith('.d.ts')
|
||||
);
|
||||
}
|
||||
|
||||
function collectSources(): string[] {
|
||||
const files: string[] = [];
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const rootDir = join(REPO_ROOT, root);
|
||||
if (!existsSync(rootDir)) continue;
|
||||
for (const pkg of readdirSync(rootDir)) {
|
||||
const srcDir = join(rootDir, pkg, 'src');
|
||||
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) continue;
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
if (entry === 'node_modules' || entry === 'dist') continue;
|
||||
walk(full);
|
||||
} else if (EXTENSIONS.has(full.slice(full.lastIndexOf('.')))) {
|
||||
const rel = relative(REPO_ROOT, full);
|
||||
if (!isTestPath(rel)) files.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(srcDir);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/** Strip line and block comments so commented-out code cannot trip prongs. */
|
||||
function stripComments(src: string): string {
|
||||
return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||||
}
|
||||
|
||||
/** Extract string and template literal spans (approximation, multi-line for templates). */
|
||||
function stringSpans(src: string): string[] {
|
||||
const spans: string[] = [];
|
||||
const re = /`[^`]*`|'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/gs;
|
||||
for (const m of src.matchAll(re)) spans.push(m[0]);
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** Local names (including aliases) under which class-table symbols are imported. */
|
||||
function classSymbolAliases(src: string): string[] {
|
||||
const names = new Set<string>();
|
||||
const importRe = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
||||
for (const m of src.matchAll(importRe)) {
|
||||
const specifier = m[2]!;
|
||||
if (!/@mosaicstack\/db|\.\.?\/(?:.*\/)?(?:schema|index)(?:\.js)?$/.test(specifier)) continue;
|
||||
for (const part of m[1]!.split(',')) {
|
||||
const seg = part.trim().replace(/^type\s+/, '');
|
||||
if (!seg) continue;
|
||||
const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(seg);
|
||||
const original = asMatch ? asMatch[1]! : seg;
|
||||
const local = asMatch ? asMatch[2]! : seg;
|
||||
if (CLASS_SYMBOLS.includes(original)) names.add(local);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
interface Violation {
|
||||
file: string;
|
||||
prong: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
describe('hierarchy writer coverage (contract 1 §6.3b)', () => {
|
||||
const sources = collectSources();
|
||||
|
||||
it('scans a non-empty production source set', () => {
|
||||
expect(sources.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('enumerated modules exist on disk (no stale allowlist/register entries)', () => {
|
||||
for (const p of [
|
||||
...WRITER_ALLOWLIST,
|
||||
...INFRA_REGISTER,
|
||||
...MIGRATION_RUNNER_IMPORTERS,
|
||||
...MIGRATE_TIER_IMPORTERS,
|
||||
]) {
|
||||
expect(existsSync(join(REPO_ROOT, p)), `enumerated module missing: ${p}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('no registered module appears on the writer allowlist', () => {
|
||||
for (const p of INFRA_REGISTER) {
|
||||
expect(WRITER_ALLOWLIST, `register/allowlist overlap: ${p}`).not.toContain(p);
|
||||
}
|
||||
});
|
||||
|
||||
it('three-prong writer coverage holds', () => {
|
||||
const violations: Violation[] = [];
|
||||
const allow = new Set(WRITER_ALLOWLIST);
|
||||
const register = new Set(INFRA_REGISTER);
|
||||
|
||||
for (const rel of sources) {
|
||||
const raw = readFileSync(join(REPO_ROOT, rel), 'utf8');
|
||||
const src = stripComments(raw);
|
||||
const inAllowlist = allow.has(rel);
|
||||
const inRegister = register.has(rel);
|
||||
const isSchemaDefinition = rel === 'packages/db/src/schema.ts';
|
||||
|
||||
// Runtime code construction: fails anywhere.
|
||||
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(src)) {
|
||||
violations.push({ file: rel, prong: 'code-construction', detail: 'eval/new Function' });
|
||||
}
|
||||
// Non-literal dynamic import: makes the import graph unanalyzable.
|
||||
if (/\bimport\s*\(\s*(?!['"`])/.test(src)) {
|
||||
violations.push({ file: rel, prong: 'dynamic-import', detail: 'non-literal import()' });
|
||||
}
|
||||
|
||||
// Prong (i): schema-symbol writes — alias-aware.
|
||||
if (!inAllowlist) {
|
||||
const aliases = classSymbolAliases(src);
|
||||
if (aliases.length > 0) {
|
||||
const writeRe = new RegExp(
|
||||
`\\.(insert|update|delete)\\s*\\(\\s*(${aliases.join('|')})\\b`,
|
||||
'g',
|
||||
);
|
||||
for (const m of src.matchAll(writeRe)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'i-symbol',
|
||||
detail: `.${m[1]}(${m[2]}) outside the writer allowlist`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prong (ii): class-table names in SQL strings/templates.
|
||||
// Schema definitions and generated migrations are excluded (contract);
|
||||
// migrations live outside the scanned source roots already.
|
||||
if (!inAllowlist && !isSchemaDefinition) {
|
||||
const tableRe = new RegExp(`\\b(${CLASS_TABLES.join('|')})\\b`);
|
||||
const sqlContextRe =
|
||||
/\b(select|insert\s+into|update|delete\s+from|join|truncate|alter\s+table|drop\s+table|references)\b/i;
|
||||
for (const span of stringSpans(src)) {
|
||||
if (tableRe.test(span) && sqlContextRe.test(span)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
prong: 'ii-literal',
|
||||
detail: `class-table name in SQL context: ${span.slice(0, 80)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prong (iii): raw-execution primitives outside allowlist ∪ register.
|
||||
if (!inAllowlist && !inRegister) {
|
||||
const rawPatterns: Array<[RegExp, string]> = [
|
||||
[/\bsql\.raw\s*\(/, 'sql.raw()'],
|
||||
[/\.unsafe\s*\(/, '.unsafe()'],
|
||||
// `.execute(sql\`...\`)` is exempt: the tagged template keeps the
|
||||
// SQL literal in source, where prong (ii) scans it. The flagged
|
||||
// forms are the ones whose SQL content is not statically visible
|
||||
// at the call site: `.execute(variable)` and `.execute("string")`.
|
||||
[/\b(?:db|database|tx|trx)\.execute\s*\((?!\s*sql`)/, 'raw db.execute()'],
|
||||
[/from\s*['"](?:postgres|pg|@electric-sql\/pglite)['"]/, 'direct driver import'],
|
||||
[
|
||||
/\bimport\s*\(\s*['"](?:postgres|pg|@electric-sql\/pglite)['"]\s*\)/,
|
||||
'dynamic driver import',
|
||||
],
|
||||
];
|
||||
for (const [re, label] of rawPatterns) {
|
||||
if (re.test(src)) {
|
||||
violations.push({ file: rel, prong: 'iii-raw-execution', detail: label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
violations,
|
||||
violations.map((v) => `[prong ${v.prong}] ${v.file}: ${v.detail}`).join('\n'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('migration-runner import edges are exactly the closed importer enumeration', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sources) {
|
||||
if (rel.startsWith('packages/db/src/')) continue; // the runner's own package
|
||||
const src = stripComments(readFileSync(join(REPO_ROOT, rel), 'utf8'));
|
||||
const symbolRe = new RegExp(`\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b`);
|
||||
if (!symbolRe.test(src)) continue;
|
||||
// An import edge is a static value import from the db package (or its
|
||||
// migrate module) naming a runner symbol, or a literal dynamic
|
||||
// import('@mosaicstack/db') in a file that uses a runner symbol.
|
||||
// `import type` is erased at runtime and is not an edge; parameter
|
||||
// injection (the gateway schema-check module) has no edge.
|
||||
const staticEdge = new RegExp(
|
||||
`import\\s*(?!type\\b)\\{[^}]*\\b(${MIGRATION_RUNNER_SYMBOLS.join('|')})\\b[^}]*\\}\\s*from\\s*['"](@mosaicstack/db|[^'"]*migrate(\\.js)?)['"]`,
|
||||
).test(src);
|
||||
const dynamicEdge = /import\s*\(\s*['"]@mosaicstack\/db['"]\s*\)/.test(src);
|
||||
if ((staticEdge || dynamicEdge) && !MIGRATION_RUNNER_IMPORTERS.includes(rel)) {
|
||||
offenders.push(rel);
|
||||
}
|
||||
}
|
||||
expect(offenders, `unenumerated migration-runner importers:\n${offenders.join('\n')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('migrate-tier import edges are exactly the closed importer enumeration', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sources) {
|
||||
if (rel === 'packages/storage/src/migrate-tier.ts') continue;
|
||||
const src = stripComments(readFileSync(join(REPO_ROOT, rel), 'utf8'));
|
||||
// Edge = direct module-path import, or a value import of a migrate-tier
|
||||
// symbol from the storage package barrel (the barrel is itself
|
||||
// enumerated as a re-exporter, so barrel consumers must not escape).
|
||||
const pathEdge =
|
||||
/from\s*['"][^'"]*migrate-tier(\.js)?['"]/.test(src) ||
|
||||
/import\s*\(\s*['"][^'"]*migrate-tier(\.js)?['"]\s*\)/.test(src);
|
||||
const barrelEdge = new RegExp(
|
||||
`import\\s*(?!type\\b)\\{[^}]*\\b(${MIGRATE_TIER_SYMBOLS.join('|')})\\b[^}]*\\}\\s*from\\s*['"]@mosaicstack/storage['"]`,
|
||||
).test(src);
|
||||
if ((pathEdge || barrelEdge) && !MIGRATE_TIER_IMPORTERS.includes(rel)) offenders.push(rel);
|
||||
}
|
||||
expect(offenders, `unenumerated migrate-tier importers:\n${offenders.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
pgEnum,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
unique,
|
||||
check,
|
||||
real,
|
||||
integer,
|
||||
bigint,
|
||||
@@ -1048,3 +1051,104 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Hierarchy (tenancy/authorization structure record class) ────────────────
|
||||
// Contract: docs/requirements/hierarchy-schema.md (D2, ratified 2026-08-27).
|
||||
// Five tables: companies → estates → platform_projects → workspaces, plus
|
||||
// hierarchy_grants. Class rows carry parentage, naming, grant, and
|
||||
// audit-linkage data only — the column sets below are exhaustive (§2.7) and
|
||||
// witnessed against information_schema (§6.2). No owner_id: ownership is the
|
||||
// grant structure (§4.4). All writes flow through the Gateway hierarchy
|
||||
// command family only (§5.1), enforced by the writer-coverage assertion
|
||||
// (§6.3b) — do not add writers outside that allowlist.
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const estates = pgTable(
|
||||
'estates',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [unique('estates_company_slug_uniq').on(t.companyId, t.slug)],
|
||||
);
|
||||
|
||||
export const platformProjects = pgTable(
|
||||
'platform_projects',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
estateId: uuid('estate_id')
|
||||
.notNull()
|
||||
.references(() => estates.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [unique('platform_projects_estate_slug_uniq').on(t.estateId, t.slug)],
|
||||
);
|
||||
|
||||
export const workspaces = pgTable(
|
||||
'workspaces',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
platformProjectId: uuid('platform_project_id')
|
||||
.notNull()
|
||||
.references(() => platformProjects.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
|
||||
);
|
||||
|
||||
export const hierarchyGrants = pgTable(
|
||||
'hierarchy_grants',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Subject: exactly one of user/team (CHECK below). Principal FKs are
|
||||
// RESTRICT until a deletion-and-retention contract rules otherwise (§3.3).
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'restrict' }),
|
||||
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'restrict' }),
|
||||
// Target: exactly one of the three grantable levels (CHECK below).
|
||||
// Target FKs CASCADE — the one permitted cascade in the class (§3.3);
|
||||
// cascaded grant deletions are audited by the command family (§5.2).
|
||||
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }),
|
||||
estateId: uuid('estate_id').references(() => estates.id, { onDelete: 'cascade' }),
|
||||
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
|
||||
role: text('role').notNull(),
|
||||
grantedBy: text('granted_by')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'restrict' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
|
||||
check(
|
||||
'hierarchy_grants_target_check',
|
||||
sql`num_nonnulls(company_id, estate_id, platform_project_id) = 1`,
|
||||
),
|
||||
// At most one grant per (subject, target, role) across all six
|
||||
// subject×target forms — NULLS NOT DISTINCT so nullable columns
|
||||
// participate (§3.2).
|
||||
unique('hierarchy_grants_subject_target_role_uniq')
|
||||
.on(t.userId, t.teamId, t.companyId, t.estateId, t.platformProjectId, t.role)
|
||||
.nullsNotDistinct(),
|
||||
index('hierarchy_grants_company_id_idx').on(t.companyId),
|
||||
index('hierarchy_grants_estate_id_idx').on(t.estateId),
|
||||
index('hierarchy_grants_platform_project_id_idx').on(t.platformProjectId),
|
||||
index('hierarchy_grants_user_id_idx').on(t.userId),
|
||||
index('hierarchy_grants_team_id_idx').on(t.teamId),
|
||||
index('hierarchy_grants_granted_by_idx').on(t.grantedBy),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user