@venturekit-pro/tenancy 0.0.0-dev.20260525024212 → 0.0.0-dev.20260602193331

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * @venturekit-pro/tenancy
3
3
  *
4
- * Multi-tenant utilities for VentureKit.
4
+ * Multi-tenant utilities for VentureKit — context resolution,
5
+ * middleware, and tenant-lifecycle operations (suspend / archive /
6
+ * restore / hard-delete with auto-discovered FK-safe cascade).
7
+ *
5
8
  * Documentation: https://venturekit.dev
6
9
  */
7
10
  export * from './types/index.js';
8
11
  export { TenantContext, createTenantContext, getCurrentTenant, resolveTenant, } from './context/index.js';
9
12
  export * from './middleware/index.js';
13
+ export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
14
+ export type { TenantLifecycleStatus, LifecycleArgs, LifecycleOperationArgs, HardDeleteArgs, LifecycleAuditEvent, LifecycleAuditSink, CascadePlanEntry, CascadeOptions, CascadeResult, } from './lifecycle/index.js';
10
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,aAAa,GACd,MAAM,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * @venturekit-pro/tenancy
3
3
  *
4
- * Multi-tenant utilities for VentureKit.
4
+ * Multi-tenant utilities for VentureKit — context resolution,
5
+ * middleware, and tenant-lifecycle operations (suspend / archive /
6
+ * restore / hard-delete with auto-discovered FK-safe cascade).
7
+ *
5
8
  * Documentation: https://venturekit.dev
6
9
  */
7
10
  // Types
@@ -10,4 +13,6 @@ export * from './types/index.js';
10
13
  export { createTenantContext, getCurrentTenant, resolveTenant, } from './context/index.js';
11
14
  // Middleware
12
15
  export * from './middleware/index.js';
16
+ // Lifecycle (suspend / archive / restore / hard-delete + cascade walker)
17
+ export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
13
18
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,yEAAyE;AACzE,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cascade walker — finds every table in a schema with a `tenant_id`
3
+ * column and topologically deletes rows for one tenant, leaf tables
4
+ * first.
5
+ *
6
+ * Why introspect rather than enumerate?
7
+ *
8
+ * Schemas evolve every release. Hard-coding a tenant-cascade list
9
+ * guarantees it drifts. Introspecting against `information_schema`
10
+ * means the walker stays correct as the app adds tables, and the
11
+ * only invariant the app needs to maintain is "tenant-scoped
12
+ * tables have a `tenant_id` column".
13
+ *
14
+ * Topological ordering:
15
+ *
16
+ * The walker queries `pg_catalog` to build a directed graph of
17
+ * foreign-key dependencies between the tenant-scoped tables, then
18
+ * deletes leaves first so it never fights an FK constraint. Tables
19
+ * that have a self-referential FK loop are detected and surfaced
20
+ * as an error — the app must add the table to `skipTables` and
21
+ * handle that case explicitly.
22
+ *
23
+ * Soft delete vs. hard delete:
24
+ *
25
+ * - `suspendTenant` / `archiveTenant` only mutate `tenants.status`.
26
+ * Children stay intact, ready for restore.
27
+ * - `hardDeleteTenant` runs the cascade. Irreversible.
28
+ *
29
+ * `tenants.status = 'archived'` is the public "this is going away"
30
+ * signal. Operators typically wait 30-90 days before invoking the
31
+ * hard delete; the package doesn't enforce that delay (compliance
32
+ * regimes vary), but it's the recommended pattern.
33
+ */
34
+ import type { Querier } from './types.js';
35
+ export interface CascadePlanEntry {
36
+ /** Schema-qualified table name (`public.blog_posts`). */
37
+ tableName: string;
38
+ /** Topological depth — 0 = leaf (deleted first). */
39
+ depth: number;
40
+ /** Tables this table has FKs to among the tenant-scoped set. */
41
+ dependsOn: string[];
42
+ }
43
+ export interface CascadeResult {
44
+ /** Plan that was executed, in deletion order. */
45
+ plan: CascadePlanEntry[];
46
+ /** Per-table row counts deleted. */
47
+ counts: Record<string, number>;
48
+ /** Total wall-clock milliseconds. */
49
+ durationMs: number;
50
+ }
51
+ export interface CascadeOptions {
52
+ tenantId: string;
53
+ /**
54
+ * Schema to introspect. Defaults to `public` — the only schema
55
+ * most VentureKit apps use.
56
+ */
57
+ schema?: string;
58
+ /**
59
+ * Tables to skip even when they have a `tenant_id` column.
60
+ * Typical uses:
61
+ * - Cross-tenant audit / billing tables where DELETE is REVOKE'd.
62
+ * - The `tenants` table itself (handled separately at the end).
63
+ * - Tables with self-referential FK cycles that need manual
64
+ * handling.
65
+ *
66
+ * `tenants` is always implicitly skipped — the walker deletes the
67
+ * row at the end as a final step.
68
+ */
69
+ skipTables?: string[];
70
+ }
71
+ /**
72
+ * Plan the cascade WITHOUT executing it. Useful for dry-runs in the
73
+ * admin UI ("about to delete N rows from M tables — confirm?").
74
+ *
75
+ * The plan is deterministic given (schema, skipTables): re-running
76
+ * it returns the same order. Tables with a self-FK cycle inside the
77
+ * tenant-scoped set fail loud with a clear error.
78
+ */
79
+ export declare function planCascade(querier: Querier, options?: Pick<CascadeOptions, 'schema' | 'skipTables'>): Promise<CascadePlanEntry[]>;
80
+ /**
81
+ * Execute the cascade. Deletes rows for one tenant from every
82
+ * `tenant_id`-bearing table in `schema`, leaves first; finally
83
+ * deletes the row from `tenants` itself.
84
+ *
85
+ * **Destructive.** Caller must have DELETE privileges on every
86
+ * involved table. Apps typically wire this to a dedicated cron
87
+ * Lambda running under a privileged maintenance role.
88
+ *
89
+ * Transaction semantics:
90
+ * - Each table's delete runs in its own transaction by default,
91
+ * so a failure mid-cascade leaves the DB in a partially-cleaned
92
+ * state (re-running the cascade is idempotent — rows already
93
+ * deleted simply don't match the WHERE).
94
+ * - Apps that want all-or-nothing semantics wrap the call in
95
+ * their own `withTransaction()` and pass a transaction-bound
96
+ * querier in.
97
+ */
98
+ export declare function executeCascade(querier: Querier, options: CascadeOptions): Promise<CascadeResult>;
99
+ //# sourceMappingURL=cascade.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cascade.d.ts","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,iDAAiD;IACjD,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACzB,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAYD;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,YAAY,CAAM,GAC1D,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA2G7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA8BxB"}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Cascade walker — finds every table in a schema with a `tenant_id`
3
+ * column and topologically deletes rows for one tenant, leaf tables
4
+ * first.
5
+ *
6
+ * Why introspect rather than enumerate?
7
+ *
8
+ * Schemas evolve every release. Hard-coding a tenant-cascade list
9
+ * guarantees it drifts. Introspecting against `information_schema`
10
+ * means the walker stays correct as the app adds tables, and the
11
+ * only invariant the app needs to maintain is "tenant-scoped
12
+ * tables have a `tenant_id` column".
13
+ *
14
+ * Topological ordering:
15
+ *
16
+ * The walker queries `pg_catalog` to build a directed graph of
17
+ * foreign-key dependencies between the tenant-scoped tables, then
18
+ * deletes leaves first so it never fights an FK constraint. Tables
19
+ * that have a self-referential FK loop are detected and surfaced
20
+ * as an error — the app must add the table to `skipTables` and
21
+ * handle that case explicitly.
22
+ *
23
+ * Soft delete vs. hard delete:
24
+ *
25
+ * - `suspendTenant` / `archiveTenant` only mutate `tenants.status`.
26
+ * Children stay intact, ready for restore.
27
+ * - `hardDeleteTenant` runs the cascade. Irreversible.
28
+ *
29
+ * `tenants.status = 'archived'` is the public "this is going away"
30
+ * signal. Operators typically wait 30-90 days before invoking the
31
+ * hard delete; the package doesn't enforce that delay (compliance
32
+ * regimes vary), but it's the recommended pattern.
33
+ */
34
+ /**
35
+ * Plan the cascade WITHOUT executing it. Useful for dry-runs in the
36
+ * admin UI ("about to delete N rows from M tables — confirm?").
37
+ *
38
+ * The plan is deterministic given (schema, skipTables): re-running
39
+ * it returns the same order. Tables with a self-FK cycle inside the
40
+ * tenant-scoped set fail loud with a clear error.
41
+ */
42
+ export async function planCascade(querier, options = {}) {
43
+ const schema = options.schema ?? 'public';
44
+ const skip = new Set([
45
+ // `tenants` is the root we're collapsing into; the helper deletes
46
+ // it separately at the very end. Including it in the cascade
47
+ // would cause a "depends on itself" cycle.
48
+ 'tenants',
49
+ ...(options.skipTables ?? []),
50
+ ]);
51
+ const tables = await querier(`SELECT table_schema AS "schema_name", table_name
52
+ FROM information_schema.columns
53
+ WHERE table_schema = $1
54
+ AND column_name = 'tenant_id'`, [schema]);
55
+ const tenantTables = new Set(tables.map((t) => t.table_name).filter((name) => !skip.has(name)));
56
+ if (tenantTables.size === 0)
57
+ return [];
58
+ const fkEdges = await querier(`SELECT
59
+ cl.relname AS "child_table",
60
+ pl.relname AS "parent_table"
61
+ FROM pg_constraint c
62
+ JOIN pg_class cl ON cl.oid = c.conrelid
63
+ JOIN pg_class pl ON pl.oid = c.confrelid
64
+ JOIN pg_namespace n_child ON n_child.oid = cl.relnamespace
65
+ JOIN pg_namespace n_parent ON n_parent.oid = pl.relnamespace
66
+ WHERE c.contype = 'f'
67
+ AND n_child.nspname = $1
68
+ AND n_parent.nspname = $1`, [schema]);
69
+ // Build two views of the FK graph:
70
+ // - `parentToChildren` drives the topological walk. A "leaf" we can
71
+ // safely delete first is a table with *no remaining children* (no
72
+ // other tenant-scoped table holds an FK pointing at it).
73
+ // - `dependsOn` is a static snapshot of each table's outgoing FK
74
+ // targets, surfaced verbatim in the plan output.
75
+ //
76
+ // Inverting `parentToChildren` (rather than working from a `child →
77
+ // parents` map) matters here because deletion order is opposite to
78
+ // declaration order: rows in a *child* table must be removed before
79
+ // the *parent* row they reference, or the FK constraint fires.
80
+ const parentToChildren = new Map();
81
+ const dependsOnSets = new Map();
82
+ for (const t of tenantTables) {
83
+ parentToChildren.set(t, new Set());
84
+ dependsOnSets.set(t, new Set());
85
+ }
86
+ for (const edge of fkEdges) {
87
+ if (tenantTables.has(edge.child_table) &&
88
+ tenantTables.has(edge.parent_table) &&
89
+ edge.child_table !== edge.parent_table) {
90
+ parentToChildren.get(edge.parent_table).add(edge.child_table);
91
+ // Sets dedupe the (rare) case of two FK columns on the same
92
+ // table pointing to the same parent (e.g. created_by + updated_by
93
+ // → users).
94
+ dependsOnSets.get(edge.child_table).add(edge.parent_table);
95
+ }
96
+ }
97
+ const dependsOn = new Map();
98
+ for (const [t, set] of dependsOnSets) {
99
+ dependsOn.set(t, Array.from(set).sort());
100
+ }
101
+ // Topological sort: pick tables that no other tenant-scoped table
102
+ // depends on (leaves — deleted first), record their depth, remove
103
+ // them from the graph, repeat.
104
+ const plan = [];
105
+ let depth = 0;
106
+ while (parentToChildren.size > 0) {
107
+ const leaves = [];
108
+ for (const [table, children] of parentToChildren) {
109
+ if (children.size === 0)
110
+ leaves.push(table);
111
+ }
112
+ if (leaves.length === 0) {
113
+ const remaining = Array.from(parentToChildren.keys()).sort();
114
+ throw new Error(`[tenancy/cascade] FK cycle detected among tenant-scoped tables: [${remaining.join(', ')}]. ` +
115
+ `Add the offending tables to skipTables and delete them manually.`);
116
+ }
117
+ leaves.sort(); // deterministic order within a depth level
118
+ for (const t of leaves) {
119
+ plan.push({
120
+ tableName: `${schema}.${t}`,
121
+ depth,
122
+ dependsOn: dependsOn.get(t),
123
+ });
124
+ parentToChildren.delete(t);
125
+ }
126
+ // Strip the deleted leaves from every remaining table's children set.
127
+ for (const children of parentToChildren.values()) {
128
+ for (const leaf of leaves)
129
+ children.delete(leaf);
130
+ }
131
+ depth++;
132
+ }
133
+ return plan;
134
+ }
135
+ /**
136
+ * Execute the cascade. Deletes rows for one tenant from every
137
+ * `tenant_id`-bearing table in `schema`, leaves first; finally
138
+ * deletes the row from `tenants` itself.
139
+ *
140
+ * **Destructive.** Caller must have DELETE privileges on every
141
+ * involved table. Apps typically wire this to a dedicated cron
142
+ * Lambda running under a privileged maintenance role.
143
+ *
144
+ * Transaction semantics:
145
+ * - Each table's delete runs in its own transaction by default,
146
+ * so a failure mid-cascade leaves the DB in a partially-cleaned
147
+ * state (re-running the cascade is idempotent — rows already
148
+ * deleted simply don't match the WHERE).
149
+ * - Apps that want all-or-nothing semantics wrap the call in
150
+ * their own `withTransaction()` and pass a transaction-bound
151
+ * querier in.
152
+ */
153
+ export async function executeCascade(querier, options) {
154
+ const startedAt = Date.now();
155
+ const schema = options.schema ?? 'public';
156
+ const plan = await planCascade(querier, options);
157
+ const counts = {};
158
+ for (const entry of plan) {
159
+ // Use the schema-qualified name verbatim (it came from
160
+ // information_schema, no injection risk).
161
+ const rows = await querier(`DELETE FROM ${entry.tableName} WHERE tenant_id = $1 RETURNING 1 AS id`, [options.tenantId]);
162
+ counts[entry.tableName] = rows.length;
163
+ }
164
+ // Finally, delete the tenants row itself.
165
+ const tenantsTable = `${schema}.tenants`;
166
+ const rows = await querier(`DELETE FROM ${tenantsTable} WHERE id = $1 RETURNING 1 AS id`, [options.tenantId]);
167
+ counts[tenantsTable] = rows.length;
168
+ return {
169
+ plan,
170
+ counts,
171
+ durationMs: Date.now() - startedAt,
172
+ };
173
+ }
174
+ //# sourceMappingURL=cascade.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cascade.js","sourceRoot":"","sources":["../../src/lifecycle/cascade.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Tenant-lifecycle operations.
3
+ *
4
+ * - `suspendTenant` — soft, reversible.
5
+ * - `archiveTenant` — soft, reversible, hides from active UIs.
6
+ * - `restoreTenant` — flips back to `active`.
7
+ * - `hardDeleteTenant` — runs the cascade walker. Irreversible.
8
+ *
9
+ * Plus the cascade walker primitives for apps that want finer control:
10
+ *
11
+ * - `planCascade(querier, opts)` — preview the deletion plan.
12
+ * - `executeCascade(querier, opts)` — run the cascade without
13
+ * touching the `tenants` row
14
+ * (called internally by
15
+ * `hardDeleteTenant`).
16
+ */
17
+ export type { Querier, TenantLifecycleStatus, LifecycleArgs, LifecycleAuditEvent, LifecycleAuditSink, } from './types.js';
18
+ export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, } from './operations.js';
19
+ export type { LifecycleOperationArgs, HardDeleteArgs } from './operations.js';
20
+ export { planCascade, executeCascade, } from './cascade.js';
21
+ export type { CascadePlanEntry, CascadeOptions, CascadeResult, } from './cascade.js';
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,YAAY,EACV,OAAO,EACP,qBAAqB,EACrB,aAAa,EACb,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE9E,OAAO,EACL,WAAW,EACX,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,GACd,MAAM,cAAc,CAAC"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tenant-lifecycle operations.
3
+ *
4
+ * - `suspendTenant` — soft, reversible.
5
+ * - `archiveTenant` — soft, reversible, hides from active UIs.
6
+ * - `restoreTenant` — flips back to `active`.
7
+ * - `hardDeleteTenant` — runs the cascade walker. Irreversible.
8
+ *
9
+ * Plus the cascade walker primitives for apps that want finer control:
10
+ *
11
+ * - `planCascade(querier, opts)` — preview the deletion plan.
12
+ * - `executeCascade(querier, opts)` — run the cascade without
13
+ * touching the `tenants` row
14
+ * (called internally by
15
+ * `hardDeleteTenant`).
16
+ */
17
+ export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, } from './operations.js';
18
+ export { planCascade, executeCascade, } from './cascade.js';
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lifecycle/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAUH,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,WAAW,EACX,cAAc,GACf,MAAM,cAAc,CAAC"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tenant-lifecycle operations.
3
+ *
4
+ * - `suspendTenant` — `status = 'suspended'`, reversible.
5
+ * - `archiveTenant` — `status = 'archived'`, reversible (data
6
+ * still present, just hidden from active
7
+ * UIs).
8
+ * - `restoreTenant` — `status = 'active'`, reverses suspend or
9
+ * archive.
10
+ * - `hardDeleteTenant` — runs the cascade walker + drops the
11
+ * `tenants` row. **Irreversible.**
12
+ *
13
+ * The package mutates `tenants.status` plus, optionally, a metadata
14
+ * column. It does NOT touch any other table — application children
15
+ * are left intact for the reversible operations, and only the
16
+ * cascade walker touches them for the hard-delete path.
17
+ *
18
+ * Audit hook:
19
+ * - Every operation calls the supplied `audit` callback after
20
+ * mutating the DB. Apps wire this to `@venturekit-pro/audit`'s
21
+ * `record()` (or whatever audit sink they use).
22
+ * - The hook receives a typed event — see `types.ts` for the
23
+ * shape.
24
+ */
25
+ import { type CascadeResult } from './cascade.js';
26
+ import type { LifecycleArgs, LifecycleAuditSink, Querier } from './types.js';
27
+ export interface LifecycleOperationArgs extends LifecycleArgs {
28
+ /** Optional audit sink invoked once per operation. */
29
+ audit?: LifecycleAuditSink;
30
+ }
31
+ export interface HardDeleteArgs extends LifecycleOperationArgs {
32
+ /** Schema to operate against. Default `public`. */
33
+ schema?: string;
34
+ /** Tables to exclude from the cascade (`tenants` is always implicit). */
35
+ skipTables?: string[];
36
+ }
37
+ /**
38
+ * Mark a tenant as `suspended`. Reversible via `restoreTenant`.
39
+ *
40
+ * Application code should refuse to serve requests for suspended
41
+ * tenants — typically a check in the tenancy middleware:
42
+ * `if (tenant.status === 'suspended') throw 403`.
43
+ */
44
+ export declare function suspendTenant(querier: Querier, args: LifecycleOperationArgs): Promise<void>;
45
+ /**
46
+ * Mark a tenant as `archived`. Same reversibility as suspend; the
47
+ * intent is "this tenant is going away soon" so admin UIs hide it
48
+ * but data is preserved for compliance + potential restore.
49
+ */
50
+ export declare function archiveTenant(querier: Querier, args: LifecycleOperationArgs): Promise<void>;
51
+ /**
52
+ * Flip a `suspended` / `archived` tenant back to `active`.
53
+ */
54
+ export declare function restoreTenant(querier: Querier, args: LifecycleOperationArgs): Promise<void>;
55
+ /**
56
+ * Run the cascade walker + drop the `tenants` row. Irreversible.
57
+ *
58
+ * Caller must have DELETE privileges on every tenant-scoped table.
59
+ * Apps typically wire this to a dedicated cron Lambda running under
60
+ * a privileged maintenance role.
61
+ *
62
+ * Audit event includes the per-table row-deletion counts so the
63
+ * compliance log knows exactly what disappeared.
64
+ */
65
+ export declare function hardDeleteTenant(querier: Querier, args: HardDeleteArgs): Promise<CascadeResult>;
66
+ //# sourceMappingURL=operations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../../src/lifecycle/operations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EACV,aAAa,EAEb,kBAAkB,EAClB,OAAO,EAER,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,sBAAuB,SAAQ,aAAa;IAC3D,sDAAsD;IACtD,KAAK,CAAC,EAAE,kBAAkB,CAAC;CAC5B;AAED,MAAM,WAAW,cAAe,SAAQ,sBAAsB;IAC5D,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAID;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAUf;AAID;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAUf;AAID;;GAEG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAUf;AAID;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,aAAa,CAAC,CAgBxB"}
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Tenant-lifecycle operations.
3
+ *
4
+ * - `suspendTenant` — `status = 'suspended'`, reversible.
5
+ * - `archiveTenant` — `status = 'archived'`, reversible (data
6
+ * still present, just hidden from active
7
+ * UIs).
8
+ * - `restoreTenant` — `status = 'active'`, reverses suspend or
9
+ * archive.
10
+ * - `hardDeleteTenant` — runs the cascade walker + drops the
11
+ * `tenants` row. **Irreversible.**
12
+ *
13
+ * The package mutates `tenants.status` plus, optionally, a metadata
14
+ * column. It does NOT touch any other table — application children
15
+ * are left intact for the reversible operations, and only the
16
+ * cascade walker touches them for the hard-delete path.
17
+ *
18
+ * Audit hook:
19
+ * - Every operation calls the supplied `audit` callback after
20
+ * mutating the DB. Apps wire this to `@venturekit-pro/audit`'s
21
+ * `record()` (or whatever audit sink they use).
22
+ * - The hook receives a typed event — see `types.ts` for the
23
+ * shape.
24
+ */
25
+ import { executeCascade } from './cascade.js';
26
+ // ─── suspend ───────────────────────────────────────────────────────────
27
+ /**
28
+ * Mark a tenant as `suspended`. Reversible via `restoreTenant`.
29
+ *
30
+ * Application code should refuse to serve requests for suspended
31
+ * tenants — typically a check in the tenancy middleware:
32
+ * `if (tenant.status === 'suspended') throw 403`.
33
+ */
34
+ export async function suspendTenant(querier, args) {
35
+ await mutateStatus(querier, args.tenantId, 'suspended', args.reason);
36
+ await emit(args.audit, {
37
+ kind: 'tenant.lifecycle',
38
+ action: 'suspend',
39
+ tenantId: args.tenantId,
40
+ actorUserId: args.actorUserId,
41
+ reason: args.reason,
42
+ occurredAt: new Date().toISOString(),
43
+ });
44
+ }
45
+ // ─── archive ──────────────────────────────────────────────────────────
46
+ /**
47
+ * Mark a tenant as `archived`. Same reversibility as suspend; the
48
+ * intent is "this tenant is going away soon" so admin UIs hide it
49
+ * but data is preserved for compliance + potential restore.
50
+ */
51
+ export async function archiveTenant(querier, args) {
52
+ await mutateStatus(querier, args.tenantId, 'archived', args.reason);
53
+ await emit(args.audit, {
54
+ kind: 'tenant.lifecycle',
55
+ action: 'archive',
56
+ tenantId: args.tenantId,
57
+ actorUserId: args.actorUserId,
58
+ reason: args.reason,
59
+ occurredAt: new Date().toISOString(),
60
+ });
61
+ }
62
+ // ─── restore ──────────────────────────────────────────────────────────
63
+ /**
64
+ * Flip a `suspended` / `archived` tenant back to `active`.
65
+ */
66
+ export async function restoreTenant(querier, args) {
67
+ await mutateStatus(querier, args.tenantId, 'active', args.reason);
68
+ await emit(args.audit, {
69
+ kind: 'tenant.lifecycle',
70
+ action: 'restore',
71
+ tenantId: args.tenantId,
72
+ actorUserId: args.actorUserId,
73
+ reason: args.reason,
74
+ occurredAt: new Date().toISOString(),
75
+ });
76
+ }
77
+ // ─── hard delete ──────────────────────────────────────────────────────
78
+ /**
79
+ * Run the cascade walker + drop the `tenants` row. Irreversible.
80
+ *
81
+ * Caller must have DELETE privileges on every tenant-scoped table.
82
+ * Apps typically wire this to a dedicated cron Lambda running under
83
+ * a privileged maintenance role.
84
+ *
85
+ * Audit event includes the per-table row-deletion counts so the
86
+ * compliance log knows exactly what disappeared.
87
+ */
88
+ export async function hardDeleteTenant(querier, args) {
89
+ const result = await executeCascade(querier, {
90
+ tenantId: args.tenantId,
91
+ schema: args.schema,
92
+ skipTables: args.skipTables,
93
+ });
94
+ await emit(args.audit, {
95
+ kind: 'tenant.lifecycle',
96
+ action: 'hard_delete',
97
+ tenantId: args.tenantId,
98
+ actorUserId: args.actorUserId,
99
+ reason: args.reason,
100
+ cascadeCounts: result.counts,
101
+ occurredAt: new Date().toISOString(),
102
+ });
103
+ return result;
104
+ }
105
+ // ─── Internal helpers ─────────────────────────────────────────────────
106
+ async function mutateStatus(querier, tenantId, status, reason) {
107
+ // We update `status` directly. Reason flows into the optional
108
+ // `metadata` jsonb column when present; we use jsonb_set so apps
109
+ // that don't have that column simply don't define it (no-op).
110
+ //
111
+ // The UPDATE is intentionally narrow: only the columns we own. Any
112
+ // app-side derived state (e.g. cached counters) is the app's
113
+ // responsibility to refresh.
114
+ if (reason !== undefined && reason !== null) {
115
+ await querier(`UPDATE tenants
116
+ SET status = $2,
117
+ metadata = jsonb_set(
118
+ COALESCE(metadata, '{}'::jsonb),
119
+ '{lifecycleReason}',
120
+ to_jsonb($3::text),
121
+ true
122
+ ),
123
+ updated_at = now()
124
+ WHERE id = $1`, [tenantId, status, reason]);
125
+ }
126
+ else {
127
+ await querier(`UPDATE tenants
128
+ SET status = $2,
129
+ updated_at = now()
130
+ WHERE id = $1`, [tenantId, status]);
131
+ }
132
+ }
133
+ async function emit(sink, event) {
134
+ if (!sink)
135
+ return;
136
+ await sink(event);
137
+ }
138
+ //# sourceMappingURL=operations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operations.js","sourceRoot":"","sources":["../../src/lifecycle/operations.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Types for tenant-lifecycle operations.
3
+ *
4
+ * `Querier` is structurally compatible with `@venturekit/data`'s
5
+ * `Querier` and `@venturekit-pro/audit`'s `Querier`. We re-declare
6
+ * it locally rather than peer-depend so the package builds in
7
+ * isolation and works with any caller's pg-like driver adapter.
8
+ */
9
+ export type Querier = <T = Record<string, unknown>[]>(sql: string, params?: unknown[]) => Promise<T>;
10
+ /** Lifecycle status this package mutates `tenants.status` between. */
11
+ export type TenantLifecycleStatus = 'active' | 'suspended' | 'archived';
12
+ export interface LifecycleArgs {
13
+ tenantId: string;
14
+ /** Optional Cognito sub of the operator — surfaced in the audit hook below. */
15
+ actorUserId?: string;
16
+ /** Optional reason — written to `tenants.metadata.lifecycleReason` when set. */
17
+ reason?: string;
18
+ }
19
+ /**
20
+ * Audit hook: called with one event per lifecycle action. Apps
21
+ * forward this to `@venturekit-pro/audit`'s `record()` (or their
22
+ * own log). Optional — when omitted, lifecycle ops happen silently.
23
+ */
24
+ export interface LifecycleAuditEvent {
25
+ /** Always `'tenant.lifecycle'`. */
26
+ kind: 'tenant.lifecycle';
27
+ /** Which lifecycle action fired. */
28
+ action: 'suspend' | 'archive' | 'restore' | 'hard_delete';
29
+ tenantId: string;
30
+ actorUserId?: string;
31
+ reason?: string;
32
+ /** For hard delete: the table name → number of rows removed. */
33
+ cascadeCounts?: Record<string, number>;
34
+ occurredAt: string;
35
+ }
36
+ export type LifecycleAuditSink = (event: LifecycleAuditEvent) => void | Promise<void>;
37
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lifecycle/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAClD,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,KACf,OAAO,CAAC,CAAC,CAAC,CAAC;AAEhB,sEAAsE;AACtE,MAAM,MAAM,qBAAqB,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,CAAC;AAExE,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,mCAAmC;IACnC,IAAI,EAAE,kBAAkB,CAAC;IACzB,oCAAoC;IACpC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Types for tenant-lifecycle operations.
3
+ *
4
+ * `Querier` is structurally compatible with `@venturekit/data`'s
5
+ * `Querier` and `@venturekit-pro/audit`'s `Querier`. We re-declare
6
+ * it locally rather than peer-depend so the package builds in
7
+ * isolation and works with any caller's pg-like driver adapter.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/lifecycle/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
@@ -0,0 +1,105 @@
1
+ -- @venturekit-pro/tenancy — primordial `tenants` table.
2
+ --
3
+ -- # Why the `0000_` filename prefix
4
+ --
5
+ -- VentureKit's migration runner merges all .sql files from the
6
+ -- project + every installed `@venturekit/*` / `@venturekit-pro/*`
7
+ -- package into a single directory, then applies them in alphabetic
8
+ -- order. The CMS (and every other VK app) ships migrations starting
9
+ -- at `0001_*`, and most pro packages use the `vk_<pkg>_<NNNN>_*`
10
+ -- naming convention — both of which sort *after* this file.
11
+ --
12
+ -- This migration ships the table every other migration references
13
+ -- (`tenant_id REFERENCES tenants(id)`), so it MUST run first. The
14
+ -- `0000_` prefix is a one-character departure from the standard
15
+ -- `vk_<pkg>_*` convention, justified by primordial-parent status.
16
+ --
17
+ -- # What lives here vs in app-specific tables
18
+ --
19
+ -- The lean shape below is *only* what every multi-tenant VK app
20
+ -- needs:
21
+ -- - identity (`id`, `slug`, `display_name`)
22
+ -- - lifecycle (`status` + `archive_*` + `suspend_*`)
23
+ -- - routing (`primary_domain`)
24
+ -- - timestamps
25
+ --
26
+ -- App-specific overlays — branding, editorial cadence, retention
27
+ -- policies, etc. — live in *their own* table 1:1 by `tenant_id`
28
+ -- (see e.g. `tenant_cms_settings` shipped by `@venturekit-cms`).
29
+ -- This split keeps `tenants` writable from any app's tenancy-admin
30
+ -- flow without inter-app schema collisions.
31
+ --
32
+ -- # No `created_by` / `updated_by` here
33
+ --
34
+ -- Audit of *who* mutated the tenant lives in `audit_events`
35
+ -- (`@venturekit-pro/audit`). Storing FK references to a per-app
36
+ -- `users` table on the tenancy-owned row would create cross-package
37
+ -- FK coupling and a chicken-egg problem (the first user is created
38
+ -- *after* the tenant). The audit ledger answers the "who" question
39
+ -- without that coupling.
40
+
41
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
42
+
43
+ -- ─── Tenant lifecycle status ───────────────────────────────────────
44
+ -- Mirror of `@venturekit-pro/tenancy`'s `TenantStatus` TS type at
45
+ -- `src/types/tenant.ts`. The two MUST stay in lockstep — the runtime
46
+ -- middleware (`tenant-middleware.ts`) drives 403 / 410 / 503
47
+ -- responses off this column.
48
+
49
+ CREATE TYPE tenant_status AS ENUM (
50
+ 'pending', -- pre-provisioning, no users yet
51
+ 'active', -- normal operation
52
+ 'suspended', -- read-only or hard-locked (see `suspend_reason`)
53
+ 'archived', -- soft-deleted; rows retained for the retention
54
+ -- window per the lifecycle cascade
55
+ 'deleted' -- final tombstone; cascade has run
56
+ );
57
+
58
+ -- ─── tenants ───────────────────────────────────────────────────────
59
+
60
+ CREATE TABLE IF NOT EXISTS tenants (
61
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
62
+ -- URL-safe identifier. Used for sub-domains, log lines, S3
63
+ -- prefixes (`<slug>/...`), and the tenant-routing middleware.
64
+ slug varchar(64) NOT NULL,
65
+ -- Human-readable label rendered in admin UIs / billing portals.
66
+ display_name text NOT NULL,
67
+ -- Lifecycle gate consulted by every authed request.
68
+ status tenant_status NOT NULL DEFAULT 'pending',
69
+ -- The host the tenant is reachable at. The tenancy middleware
70
+ -- looks up by this when routing `Host:` headers. Unique because
71
+ -- one tenant per host.
72
+ primary_domain varchar(255),
73
+ -- Set when status flipped to 'suspended'. Surfaced in the 503
74
+ -- response body so support can tell the operator why.
75
+ suspended_at timestamptz,
76
+ suspend_reason text,
77
+ -- Set when status flipped to 'archived'. The lifecycle cascade
78
+ -- uses this to compute the deletion countdown (retention window
79
+ -- is per-app, stored in the app's own settings table).
80
+ archived_at timestamptz,
81
+ archive_reason text,
82
+ -- Free-form metadata bag for the operator. App-specific overlays
83
+ -- should live in their own table (e.g. `tenant_cms_settings`),
84
+ -- not here.
85
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
86
+ created_at timestamptz NOT NULL DEFAULT now(),
87
+ updated_at timestamptz NOT NULL DEFAULT now()
88
+ );
89
+
90
+ -- ─── Indexes ───────────────────────────────────────────────────────
91
+
92
+ -- Slug is the editor-friendly id used in log lines and S3 prefixes.
93
+ -- Unique on lower(slug) so `Demo` and `demo` collide.
94
+ CREATE UNIQUE INDEX IF NOT EXISTS tenants_slug_unique
95
+ ON tenants (lower(slug));
96
+
97
+ -- Primary-domain lookup is the hot path for the tenancy middleware
98
+ -- (one query per request before any business logic runs).
99
+ CREATE UNIQUE INDEX IF NOT EXISTS tenants_primary_domain_unique
100
+ ON tenants (lower(primary_domain))
101
+ WHERE primary_domain IS NOT NULL;
102
+
103
+ -- Status filter for admin "active tenants" dashboards.
104
+ CREATE INDEX IF NOT EXISTS tenants_status_idx
105
+ ON tenants (status);
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@venturekit-pro/tenancy",
3
- "version": "0.0.0-dev.20260525024212",
3
+ "version": "0.0.0-dev.20260602193331",
4
4
  "description": "Multi-tenant utilities for VentureKit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "files": [
9
- "dist"
9
+ "dist",
10
+ "src/migrations/*.sql"
10
11
  ],
12
+ "vk": {
13
+ "migrations": "src/migrations"
14
+ },
11
15
  "repository": {
12
16
  "type": "git",
13
17
  "url": "https://github.com/venturekit-dev/venturekit.private.git",
@@ -26,10 +30,10 @@
26
30
  }
27
31
  },
28
32
  "dependencies": {
29
- "@venturekit/core": "0.0.0-dev.20260525024212"
33
+ "@venturekit/core": "0.0.0-dev.20260602193331"
30
34
  },
31
35
  "peerDependencies": {
32
- "@venturekit/runtime": "0.0.0-dev.20260525024212"
36
+ "@venturekit/runtime": "0.0.0-dev.20260602193331"
33
37
  },
34
38
  "peerDependenciesMeta": {
35
39
  "@venturekit/runtime": {
@@ -38,11 +42,11 @@
38
42
  },
39
43
  "devDependencies": {
40
44
  "@types/node": "^25.6.0",
41
- "@venturekit/runtime": "0.0.0-dev.20260525024212",
45
+ "@venturekit/runtime": "0.0.0-dev.20260602193331",
42
46
  "typescript": "^5.3.0"
43
47
  },
44
48
  "scripts": {
45
- "build": "tsc",
49
+ "build": "tsc && node -e \"require('fs').cpSync('src/migrations','dist/migrations',{recursive:true})\"",
46
50
  "dev": "tsc --watch",
47
51
  "clean": "rm -rf dist"
48
52
  }
@@ -0,0 +1,105 @@
1
+ -- @venturekit-pro/tenancy — primordial `tenants` table.
2
+ --
3
+ -- # Why the `0000_` filename prefix
4
+ --
5
+ -- VentureKit's migration runner merges all .sql files from the
6
+ -- project + every installed `@venturekit/*` / `@venturekit-pro/*`
7
+ -- package into a single directory, then applies them in alphabetic
8
+ -- order. The CMS (and every other VK app) ships migrations starting
9
+ -- at `0001_*`, and most pro packages use the `vk_<pkg>_<NNNN>_*`
10
+ -- naming convention — both of which sort *after* this file.
11
+ --
12
+ -- This migration ships the table every other migration references
13
+ -- (`tenant_id REFERENCES tenants(id)`), so it MUST run first. The
14
+ -- `0000_` prefix is a one-character departure from the standard
15
+ -- `vk_<pkg>_*` convention, justified by primordial-parent status.
16
+ --
17
+ -- # What lives here vs in app-specific tables
18
+ --
19
+ -- The lean shape below is *only* what every multi-tenant VK app
20
+ -- needs:
21
+ -- - identity (`id`, `slug`, `display_name`)
22
+ -- - lifecycle (`status` + `archive_*` + `suspend_*`)
23
+ -- - routing (`primary_domain`)
24
+ -- - timestamps
25
+ --
26
+ -- App-specific overlays — branding, editorial cadence, retention
27
+ -- policies, etc. — live in *their own* table 1:1 by `tenant_id`
28
+ -- (see e.g. `tenant_cms_settings` shipped by `@venturekit-cms`).
29
+ -- This split keeps `tenants` writable from any app's tenancy-admin
30
+ -- flow without inter-app schema collisions.
31
+ --
32
+ -- # No `created_by` / `updated_by` here
33
+ --
34
+ -- Audit of *who* mutated the tenant lives in `audit_events`
35
+ -- (`@venturekit-pro/audit`). Storing FK references to a per-app
36
+ -- `users` table on the tenancy-owned row would create cross-package
37
+ -- FK coupling and a chicken-egg problem (the first user is created
38
+ -- *after* the tenant). The audit ledger answers the "who" question
39
+ -- without that coupling.
40
+
41
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
42
+
43
+ -- ─── Tenant lifecycle status ───────────────────────────────────────
44
+ -- Mirror of `@venturekit-pro/tenancy`'s `TenantStatus` TS type at
45
+ -- `src/types/tenant.ts`. The two MUST stay in lockstep — the runtime
46
+ -- middleware (`tenant-middleware.ts`) drives 403 / 410 / 503
47
+ -- responses off this column.
48
+
49
+ CREATE TYPE tenant_status AS ENUM (
50
+ 'pending', -- pre-provisioning, no users yet
51
+ 'active', -- normal operation
52
+ 'suspended', -- read-only or hard-locked (see `suspend_reason`)
53
+ 'archived', -- soft-deleted; rows retained for the retention
54
+ -- window per the lifecycle cascade
55
+ 'deleted' -- final tombstone; cascade has run
56
+ );
57
+
58
+ -- ─── tenants ───────────────────────────────────────────────────────
59
+
60
+ CREATE TABLE IF NOT EXISTS tenants (
61
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
62
+ -- URL-safe identifier. Used for sub-domains, log lines, S3
63
+ -- prefixes (`<slug>/...`), and the tenant-routing middleware.
64
+ slug varchar(64) NOT NULL,
65
+ -- Human-readable label rendered in admin UIs / billing portals.
66
+ display_name text NOT NULL,
67
+ -- Lifecycle gate consulted by every authed request.
68
+ status tenant_status NOT NULL DEFAULT 'pending',
69
+ -- The host the tenant is reachable at. The tenancy middleware
70
+ -- looks up by this when routing `Host:` headers. Unique because
71
+ -- one tenant per host.
72
+ primary_domain varchar(255),
73
+ -- Set when status flipped to 'suspended'. Surfaced in the 503
74
+ -- response body so support can tell the operator why.
75
+ suspended_at timestamptz,
76
+ suspend_reason text,
77
+ -- Set when status flipped to 'archived'. The lifecycle cascade
78
+ -- uses this to compute the deletion countdown (retention window
79
+ -- is per-app, stored in the app's own settings table).
80
+ archived_at timestamptz,
81
+ archive_reason text,
82
+ -- Free-form metadata bag for the operator. App-specific overlays
83
+ -- should live in their own table (e.g. `tenant_cms_settings`),
84
+ -- not here.
85
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
86
+ created_at timestamptz NOT NULL DEFAULT now(),
87
+ updated_at timestamptz NOT NULL DEFAULT now()
88
+ );
89
+
90
+ -- ─── Indexes ───────────────────────────────────────────────────────
91
+
92
+ -- Slug is the editor-friendly id used in log lines and S3 prefixes.
93
+ -- Unique on lower(slug) so `Demo` and `demo` collide.
94
+ CREATE UNIQUE INDEX IF NOT EXISTS tenants_slug_unique
95
+ ON tenants (lower(slug));
96
+
97
+ -- Primary-domain lookup is the hot path for the tenancy middleware
98
+ -- (one query per request before any business logic runs).
99
+ CREATE UNIQUE INDEX IF NOT EXISTS tenants_primary_domain_unique
100
+ ON tenants (lower(primary_domain))
101
+ WHERE primary_domain IS NOT NULL;
102
+
103
+ -- Status filter for admin "active tenants" dashboards.
104
+ CREATE INDEX IF NOT EXISTS tenants_status_idx
105
+ ON tenants (status);