@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 +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/cascade.d.ts +99 -0
- package/dist/lifecycle/cascade.d.ts.map +1 -0
- package/dist/lifecycle/cascade.js +174 -0
- package/dist/lifecycle/cascade.js.map +1 -0
- package/dist/lifecycle/index.d.ts +22 -0
- package/dist/lifecycle/index.d.ts.map +1 -0
- package/dist/lifecycle/index.js +19 -0
- package/dist/lifecycle/index.js.map +1 -0
- package/dist/lifecycle/operations.d.ts +66 -0
- package/dist/lifecycle/operations.d.ts.map +1 -0
- package/dist/lifecycle/operations.js +138 -0
- package/dist/lifecycle/operations.js.map +1 -0
- package/dist/lifecycle/types.d.ts +37 -0
- package/dist/lifecycle/types.d.ts.map +1 -0
- package/dist/lifecycle/types.js +10 -0
- package/dist/lifecycle/types.js.map +1 -0
- package/dist/migrations/0000_vk_tenancy_tenants.sql +105 -0
- package/package.json +10 -6
- package/src/migrations/0000_vk_tenancy_tenants.sql +105 -0
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAqDH;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,OAAgB,EAChB,UAAyD,EAAE;IAE3D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC;IAC1C,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC;QACnB,kEAAkE;QAClE,6DAA6D;QAC7D,2CAA2C;QAC3C,SAAS;QACT,GAAG,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;KAC9B,CAAC,CAAC;IAEH,MAAM,MAAM,GAAG,MAAM,OAAO,CAC1B;;;uCAGmC,EACnC,CAAC,MAAM,CAAC,CACT,CAAC;IAEF,MAAM,YAAY,GAAG,IAAI,GAAG,CAC1B,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAClE,CAAC;IACF,IAAI,YAAY,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,MAAM,OAAO,GAAG,MAAM,OAAO,CAC3B;;;;;;;;;;iCAU6B,EAC7B,CAAC,MAAM,CAAC,CACT,CAAC;IAEF,mCAAmC;IACnC,sEAAsE;IACtE,sEAAsE;IACtE,6DAA6D;IAC7D,mEAAmE;IACnE,qDAAqD;IACrD,EAAE;IACF,oEAAoE;IACpE,mEAAmE;IACnE,oEAAoE;IACpE,+DAA+D;IAC/D,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAuB,CAAC;IACxD,MAAM,aAAa,GAAG,IAAI,GAAG,EAAuB,CAAC;IACrD,KAAK,MAAM,CAAC,IAAI,YAAY,EAAE,CAAC;QAC7B,gBAAgB,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,EAAE,CAAC,CAAC;QACnC,aAAa,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,EAAE,CAAC,CAAC;IAClC,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;QAC3B,IACE,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC;YAClC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC;YACnC,IAAI,CAAC,WAAW,KAAK,IAAI,CAAC,YAAY,EACtC,CAAC;YACD,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,CAAE,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YAC/D,4DAA4D;YAC5D,kEAAkE;YAClE,YAAY;YACZ,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,CAAE,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC9C,KAAK,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,aAAa,EAAE,CAAC;QACrC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,kEAAkE;IAClE,kEAAkE;IAClE,+BAA+B;IAC/B,MAAM,IAAI,GAAuB,EAAE,CAAC;IACpC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,OAAO,gBAAgB,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;QACjC,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,KAAK,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,gBAAgB,EAAE,CAAC;YACjD,IAAI,QAAQ,CAAC,IAAI,KAAK,CAAC;gBAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9C,CAAC;QACD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YAC7D,MAAM,IAAI,KAAK,CACb,oEAAoE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;gBAC3F,kEAAkE,CACrE,CAAC;QACJ,CAAC;QACD,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,2CAA2C;QAC1D,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;YACvB,IAAI,CAAC,IAAI,CAAC;gBACR,SAAS,EAAE,GAAG,MAAM,IAAI,CAAC,EAAE;gBAC3B,KAAK;gBACL,SAAS,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,CAAE;aAC7B,CAAC,CAAC;YACH,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC7B,CAAC;QACD,sEAAsE;QACtE,KAAK,MAAM,QAAQ,IAAI,gBAAgB,CAAC,MAAM,EAAE,EAAE,CAAC;YACjD,KAAK,MAAM,IAAI,IAAI,MAAM;gBAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACnD,CAAC;QACD,KAAK,EAAE,CAAC;IACV,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,OAAgB,EAChB,OAAuB;IAEvB,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC7B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC;IAC1C,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAEjD,MAAM,MAAM,GAA2B,EAAE,CAAC;IAE1C,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,uDAAuD;QACvD,0CAA0C;QAC1C,MAAM,IAAI,GAAG,MAAM,OAAO,CACxB,eAAe,KAAK,CAAC,SAAS,yCAAyC,EACvE,CAAC,OAAO,CAAC,QAAQ,CAAC,CACnB,CAAC;QACF,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;IACxC,CAAC;IAED,0CAA0C;IAC1C,MAAM,YAAY,GAAG,GAAG,MAAM,UAAU,CAAC;IACzC,MAAM,IAAI,GAAG,MAAM,OAAO,CACxB,eAAe,YAAY,kCAAkC,EAC7D,CAAC,OAAO,CAAC,QAAQ,CAAC,CACnB,CAAC;IACF,MAAM,CAAC,YAAY,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;IAEnC,OAAO;QACL,IAAI;QACJ,MAAM;QACN,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS;KACnC,CAAC;AACJ,CAAC"}
|
|
@@ -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.
|
|
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.
|
|
33
|
+
"@venturekit/core": "0.0.0-dev.20260602193331"
|
|
30
34
|
},
|
|
31
35
|
"peerDependencies": {
|
|
32
|
-
"@venturekit/runtime": "0.0.0-dev.
|
|
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.
|
|
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);
|