@everystack/cli 0.4.102 → 0.4.103
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/CHANGELOG.md +21 -0
- package/package.json +3 -3
- package/src/cli/authz-compile.ts +33 -10
- package/src/cli/authz-contract.ts +6 -0
- package/src/cli/authz-derive.ts +3 -1
- package/src/cli/commands/db-plan.ts +2 -0
- package/src/cli/commands/db-pull.ts +4 -2
- package/src/cli/declared-diff.ts +3 -2
- package/src/cli/edge-plan.ts +35 -0
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/lifecycle-derived.ts +77 -11
- package/src/cli/lifecycle-pull.ts +82 -7
- package/src/cli/lifecycle-verify.ts +7 -1
- package/src/cli/migration-compile.ts +4 -3
- package/src/cli/migration-generate.ts +3 -2
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-render.ts +10 -4
- package/src/cli/plain-output.ts +14 -1
- package/src/cli/schema-compile.ts +31 -3
- package/src/cli/schema-fingerprint.ts +3 -2
- package/src/cli/schema-introspect.ts +33 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- export compileDeclaredState from @everystack/cli/model
|
|
12
|
+
|
|
13
|
+
## [0.4.103] - 2026-10-23
|
|
14
|
+
|
|
15
|
+
### BREAKING CHANGES
|
|
16
|
+
|
|
17
|
+
- lifecycle delete and restore hand the batch id to the caller
|
|
18
|
+
- lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
|
|
19
|
+
- refuse dependent + where on a lifecycle parent's hasMany
|
|
20
|
+
- can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
|
|
21
|
+
- deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- index the FK column of every lifecycle dependent
|
|
26
|
+
- db:plan warns when a CREATE INDEX will block writes to a live table
|
|
27
|
+
|
|
7
28
|
## [0.4.102] - 2026-10-23
|
|
8
29
|
|
|
9
30
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.103",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"structured-headers": "1.0.1",
|
|
146
146
|
"tsx": "4.21.0",
|
|
147
147
|
"typescript": "5.9.3",
|
|
148
|
-
"@everystack/model": "0.4.
|
|
148
|
+
"@everystack/model": "0.4.24"
|
|
149
149
|
},
|
|
150
150
|
"peerDependencies": {
|
|
151
151
|
"@everystack/server": ">=0.4.0",
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
"react": "19.2.0",
|
|
193
193
|
"ts-jest": "29.4.9",
|
|
194
194
|
"zod": "3.25.67",
|
|
195
|
-
"@everystack/server": "0.4.
|
|
195
|
+
"@everystack/server": "0.4.39"
|
|
196
196
|
},
|
|
197
197
|
"scripts": {
|
|
198
198
|
"test": "jest",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -278,21 +278,38 @@ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' :
|
|
|
278
278
|
/** The policy-side spelling of a declared role — what `pg_policies` would report. */
|
|
279
279
|
const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Who deleted a row, in which batch and which transaction: the lifecycle columns a deleted reader
|
|
283
|
+
* never selects. Only a `manage` role sees them; `deletedAt` stays visible to every reader.
|
|
284
|
+
*/
|
|
285
|
+
const DELETED_READ_HIDDEN = new Set(['deletedBy', 'deletedBatch', 'deletedXid']);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `can('read', { role, deleted })` with no `columns`: it reaches deleted rows, whose deleted_by,
|
|
289
|
+
* deleted_batch and deleted_xid are set, so it grants SELECT on every other column instead of the
|
|
290
|
+
* whole table. Grants are a union: defineModel refuses a `manage` for the same role, and the policy
|
|
291
|
+
* compile refuses a second read for it, so nothing re-grants the table.
|
|
292
|
+
*/
|
|
293
|
+
const isDeletedReadOfEveryColumn = (a: Ability): boolean =>
|
|
294
|
+
a.action === 'read' && a.condition.deleted !== undefined && !isColumnAbility(a);
|
|
295
|
+
|
|
281
296
|
/**
|
|
282
297
|
* Column grants from column abilities. The action determines the PostgreSQL verb; the named role
|
|
283
298
|
* (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
|
|
284
299
|
* deduplicated, matching the introspected contract.
|
|
285
300
|
*/
|
|
286
|
-
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
301
|
+
function compileColumnGrants(abilities: readonly Ability[], fields: readonly string[] = []): Record<string, Record<string, string[]>> | undefined {
|
|
287
302
|
// Null-prototype maps, as on the introspected side (assembleContract): a role is a key, and
|
|
288
303
|
// '__proto__' passes the role-name guard, so a plain object would lose it.
|
|
289
304
|
const out: Record<string, Record<string, Set<string>>> = Object.create(null);
|
|
305
|
+
const add = (role: string, verb: string, columns: readonly string[]): void => {
|
|
306
|
+
const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
|
|
307
|
+
for (const column of columns) cols.add(toSnakeCase(column));
|
|
308
|
+
};
|
|
290
309
|
for (const a of abilities) {
|
|
310
|
+
if (isDeletedReadOfEveryColumn(a)) add(granteeKey(a.condition.role!), 'SELECT', fields.filter((f) => !DELETED_READ_HIDDEN.has(f)));
|
|
291
311
|
if (!isColumnAbility(a)) continue;
|
|
292
|
-
|
|
293
|
-
const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
|
|
294
|
-
const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
|
|
295
|
-
for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
|
|
312
|
+
add(granteeKey(a.condition.role ?? 'authenticated'), a.action === 'read' ? 'SELECT' : 'UPDATE', a.condition.columns!);
|
|
296
313
|
}
|
|
297
314
|
const grants: Record<string, Record<string, string[]>> = Object.create(null);
|
|
298
315
|
for (const [role, verbs] of Object.entries(out)) {
|
|
@@ -541,9 +558,13 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
541
558
|
const policy = (
|
|
542
559
|
name: string, command: PolicyCommand, roles: string[],
|
|
543
560
|
using: string | null, check: string | null,
|
|
561
|
+
deleted?: 'include' | 'only',
|
|
544
562
|
): void => {
|
|
545
|
-
|
|
546
|
-
|
|
563
|
+
// can('read', { deleted }): 'include' leaves the guard off this role's policy (its other
|
|
564
|
+
// restrictions stay); 'only' puts `(deleted_at IS NOT NULL)` in its place. Omitted: the guard.
|
|
565
|
+
if (lifecycleGuard && lifecycleCol && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE) && deleted !== 'include') {
|
|
566
|
+
const state = deleted === 'only' ? `(${lifecycleCol} IS NOT NULL)` : lifecycleGuard;
|
|
567
|
+
using = andPredicates(state, using) ?? state;
|
|
547
568
|
}
|
|
548
569
|
// Same normalization the live producer applies (authz-contract's `predicate`): a model
|
|
549
570
|
// pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
|
|
@@ -661,6 +682,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
661
682
|
rawPredicate(a.condition.sql ?? a.condition.where, where),
|
|
662
683
|
) ?? 'true',
|
|
663
684
|
null,
|
|
685
|
+
a.condition.deleted,
|
|
664
686
|
);
|
|
665
687
|
}
|
|
666
688
|
|
|
@@ -865,7 +887,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
865
887
|
// security downgrade via version skew.
|
|
866
888
|
rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
|
|
867
889
|
grants: compileGrants(model.abilities, model.privileges),
|
|
868
|
-
...(compileColumnGrants(model.abilities) ? { columnGrants: compileColumnGrants(model.abilities) } : {}),
|
|
890
|
+
...(compileColumnGrants(model.abilities, Object.keys(model.fields)) ? { columnGrants: compileColumnGrants(model.abilities, Object.keys(model.fields)) } : {}),
|
|
869
891
|
policies,
|
|
870
892
|
};
|
|
871
893
|
}
|
|
@@ -945,8 +967,9 @@ function compileGrants(
|
|
|
945
967
|
for (const v of verbs) set.add(v);
|
|
946
968
|
};
|
|
947
969
|
for (const a of abilities) {
|
|
948
|
-
// A column ability grants only its listed columns, never a table-level privilege
|
|
949
|
-
|
|
970
|
+
// A column ability grants only its listed columns, never a table-level privilege; nor does
|
|
971
|
+
// a deleted read (compileColumnGrants grants it every column but the three it may not see).
|
|
972
|
+
if (isColumnAbility(a) || isDeletedReadOfEveryColumn(a)) continue;
|
|
950
973
|
const verbs = verbsFor(a.action);
|
|
951
974
|
if (a.condition.role) {
|
|
952
975
|
add(granteeKey(a.condition.role), verbs);
|
|
@@ -102,6 +102,12 @@ export interface PolicyContract {
|
|
|
102
102
|
using: string | null;
|
|
103
103
|
/** `WITH CHECK` predicate (deparsed), or null when absent. */
|
|
104
104
|
check: string | null;
|
|
105
|
+
/**
|
|
106
|
+
* Set only by db:pull's lifecycle fold, never by the catalog read or the compiler: this role's
|
|
107
|
+
* read policy on a lifecycle table carries no `(deleted_at IS NULL)` guard ('include') or carries
|
|
108
|
+
* `(deleted_at IS NOT NULL)` instead ('only'), so it renders as can('read', { role, deleted }).
|
|
109
|
+
*/
|
|
110
|
+
deleted?: 'include' | 'only';
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
/**
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -587,7 +587,9 @@ function deriveGovernedRole(contract: TableContract, role: string): {
|
|
|
587
587
|
continue;
|
|
588
588
|
}
|
|
589
589
|
const frag = sqlFragment(p.using);
|
|
590
|
-
|
|
590
|
+
// A lifecycle read without the guard (or with deleted_at IS NOT NULL), marked by the fold.
|
|
591
|
+
const deleted = priv === 'SELECT' && p.deleted ? `, deleted: '${p.deleted}'` : '';
|
|
592
|
+
out.abilities.push(frag ? `can('${DML[priv]}', { role: ${tsString(role)}, sql: ${frag}${deleted} })` : `can('${DML[priv]}', { role: ${tsString(role)}${deleted} })`);
|
|
591
593
|
}
|
|
592
594
|
out.asAbility.add(priv);
|
|
593
595
|
out.usedPolicies.add(p.name);
|
|
@@ -525,6 +525,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
525
525
|
// Only when the checkout declares objects: db:apply's catch-up does nothing at all for a
|
|
526
526
|
// checkout that declares none (derived-catchup.ts), so listing drops there would be untrue.
|
|
527
527
|
derived: declaredDerived && declaredDerived.objects.length > 0 ? derivedPlan : undefined,
|
|
528
|
+
lockWarnings: plan.lockWarnings,
|
|
529
|
+
deletedReads: plan.deletedReads,
|
|
528
530
|
})) printLine(text);
|
|
529
531
|
|
|
530
532
|
if (out !== '-') {
|
|
@@ -249,6 +249,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
249
249
|
let derivedCatalog: DerivedCatalog | undefined;
|
|
250
250
|
/** Tables folded to `lifecycle: true` (lifecycle-pull.ts). */
|
|
251
251
|
let lifecycleTables: ReadonlySet<string> = new Set();
|
|
252
|
+
let lifecycleDeleteWhen: ReadonlyMap<string, string> = new Map();
|
|
252
253
|
let matviewColumns: Map<string, ColumnSchema[]> | undefined;
|
|
253
254
|
let candidatesByIdentity: Map<string, string[]> | undefined;
|
|
254
255
|
/** Each FK's exact target, by confrelid: an external target is declared from these names. */
|
|
@@ -290,6 +291,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
290
291
|
current = fold.snapshot;
|
|
291
292
|
derivedCatalog = fold.catalog;
|
|
292
293
|
lifecycleTables = fold.tables;
|
|
294
|
+
lifecycleDeleteWhen = fold.deleteWhen;
|
|
293
295
|
if (lifecycleTables.size) note(`Lifecycle: ${[...lifecycleTables].sort().join(', ')} rendered as lifecycle: true (generated columns, grants and functions folded).`);
|
|
294
296
|
const contract = fold.contract;
|
|
295
297
|
liveContract = rawContract;
|
|
@@ -492,10 +494,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
492
494
|
: undefined;
|
|
493
495
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
494
496
|
const dir = path.resolve(flags.out);
|
|
495
|
-
modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables })
|
|
497
|
+
modelFiles = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, externalDerived, lifecycleTables, lifecycleDeleteWhen })
|
|
496
498
|
.map((f) => ({ ...f, target: containedPath(dir, f.file) }));
|
|
497
499
|
} else {
|
|
498
|
-
modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables });
|
|
500
|
+
modelSource = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, fkTargets, derived: embeddedDerived, lifecycleTables, lifecycleDeleteWhen });
|
|
499
501
|
}
|
|
500
502
|
}
|
|
501
503
|
} catch (err: any) {
|
package/src/cli/declared-diff.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import type { ModelDescriptor } from '@everystack/model';
|
|
30
|
-
import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
|
|
30
|
+
import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
|
|
31
31
|
import type { SchemaSnapshot } from './schema-introspect';
|
|
32
32
|
import type { AuthzContract } from './authz-contract';
|
|
33
33
|
import { compileTableSchema, compileEnums } from './schema-compile';
|
|
@@ -57,10 +57,11 @@ export function compileDeclaredState(
|
|
|
57
57
|
): DeclaredState {
|
|
58
58
|
assertExternalTargets(models);
|
|
59
59
|
const dependents = relationDeleteActions(models);
|
|
60
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
60
61
|
const schema = opts.schema ?? 'public';
|
|
61
62
|
return {
|
|
62
63
|
snapshot: {
|
|
63
|
-
tables: models.map((m) => compileTableSchema(m, { schema, dependents })),
|
|
64
|
+
tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })),
|
|
64
65
|
enums: compileEnums(models),
|
|
65
66
|
},
|
|
66
67
|
contract: {
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* JSON — recorded as `plan_ref` on the schema_log row the apply writes.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { indexLockWarnings, type LockWarning } from './index-lock';
|
|
27
28
|
import { createHash } from 'node:crypto';
|
|
28
29
|
import type { Attachment } from './derived-source';
|
|
29
30
|
import type { OwnerApplyMode } from './derived-apply';
|
|
@@ -253,6 +254,18 @@ export interface EdgePlan {
|
|
|
253
254
|
* plans minted before this field existed; the summary falls back to the text-only reading.
|
|
254
255
|
*/
|
|
255
256
|
dark?: string[];
|
|
257
|
+
/**
|
|
258
|
+
* The plain CREATE INDEX statements that build on a live table with rows, with the catalog's row
|
|
259
|
+
* estimate — db:plan's ⚠ LOCK lines. Read at mint, like `dark`; informational, never a gate, and
|
|
260
|
+
* outside both fingerprints. Absent on plans minted before it, or when there is nothing to warn.
|
|
261
|
+
*/
|
|
262
|
+
lockWarnings?: LockWarning[];
|
|
263
|
+
/**
|
|
264
|
+
* The read policies this plan creates or replaces for a can('read', { role, deleted }) ability:
|
|
265
|
+
* db:plan's `⚠ ACCESS — role <r> can read deleted rows of <table>` lines. Informational, outside
|
|
266
|
+
* both fingerprints. Absent when there is none, and on plans minted before it.
|
|
267
|
+
*/
|
|
268
|
+
deletedReads?: DeletedRead[];
|
|
256
269
|
/** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
|
|
257
270
|
unmodeled: string[];
|
|
258
271
|
gitRef: string | null;
|
|
@@ -564,6 +577,26 @@ function unplannableReason(change: SchemaChange): string | null {
|
|
|
564
577
|
}
|
|
565
578
|
}
|
|
566
579
|
|
|
580
|
+
/** A read policy, created or replaced by this plan, that lets a role see deleted lifecycle rows. */
|
|
581
|
+
export interface DeletedRead { table: string; role: string; deleted: 'include' | 'only' }
|
|
582
|
+
|
|
583
|
+
/** The can('read', { role, deleted }) policies whose CREATE POLICY this plan runs. */
|
|
584
|
+
export function deletedReadChanges(models: readonly ModelDescriptor[], statements: readonly string[]): DeletedRead[] {
|
|
585
|
+
const out: DeletedRead[] = [];
|
|
586
|
+
for (const m of models) {
|
|
587
|
+
if (m.lifecycle !== true) continue;
|
|
588
|
+
for (const a of m.abilities) {
|
|
589
|
+
if (a.action !== 'read' || !a.condition.deleted || !a.condition.role) continue;
|
|
590
|
+
const name = `${m.table}_select_${a.condition.role.toLowerCase()}`;
|
|
591
|
+
// The emitter writes the name bare when it is a plain identifier, quoted otherwise.
|
|
592
|
+
const created = new RegExp(`^\\s*CREATE POLICY (?:"${name}"|${name}) `, 'm');
|
|
593
|
+
if (!statements.some((st) => created.test(st))) continue;
|
|
594
|
+
out.push({ table: m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table, role: a.condition.role, deleted: a.condition.deleted });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
567
600
|
export function mintEdgePlan(
|
|
568
601
|
models: ModelDescriptor[],
|
|
569
602
|
snapshot: SchemaSnapshot,
|
|
@@ -660,6 +693,8 @@ export function mintEdgePlan(
|
|
|
660
693
|
// contract. The summary is rendered later from the plan alone, and answering "does a read
|
|
661
694
|
// survive?" from statement text is what produced three false positives on a real plan.
|
|
662
695
|
dark: tablesLeftWithoutARead(statements, contract),
|
|
696
|
+
...(() => { const lockWarnings = indexLockWarnings(statements, snapshot); return lockWarnings.length ? { lockWarnings } : {}; })(),
|
|
697
|
+
...(() => { const deletedReads = deletedReadChanges(models, statements); return deletedReads.length ? { deletedReads } : {}; })(),
|
|
663
698
|
unmodeled: unmodeledTables(models, snapshot),
|
|
664
699
|
gitRef: opts.gitRef ?? null,
|
|
665
700
|
mintedBy: opts.actor ?? null,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠ LOCK — which of a plan's statements build an index on a table that already holds rows.
|
|
3
|
+
*
|
|
4
|
+
* db:apply runs a plain `CREATE INDEX` inside its one transaction. PostgreSQL takes a SHARE lock
|
|
5
|
+
* on the table for the whole build, whatever its size: reads go on, INSERT/UPDATE/DELETE wait until
|
|
6
|
+
* the build finishes and the transaction commits. On a large table that is a write outage, so the
|
|
7
|
+
* plan says so, with the catalog's row estimate (`pg_class.reltuples`) to let the reader judge.
|
|
8
|
+
*
|
|
9
|
+
* Informational only: no --confirm, no exit code, and the plan is still "Safe to apply". Applying
|
|
10
|
+
* is unchanged. The estimate rides beside the snapshot (`rowEstimates`), never inside a table, so
|
|
11
|
+
* no fingerprint reads it.
|
|
12
|
+
*
|
|
13
|
+
* A table the same plan creates is not live, so it gets no line; an estimate of 0 is an empty table,
|
|
14
|
+
* no line; -1 is a table with data that was never analyzed, "row count unknown"; a read without
|
|
15
|
+
* estimates (an older ops Lambda) still warns, without a count.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { statementBody } from './sql-statement';
|
|
19
|
+
import type { SchemaSnapshot } from './schema-introspect';
|
|
20
|
+
|
|
21
|
+
export interface LockWarning {
|
|
22
|
+
index: string;
|
|
23
|
+
/** Schema-qualified, as the snapshot names it. */
|
|
24
|
+
table: string;
|
|
25
|
+
/** The estimate; null when the table has data but was never analyzed; absent when not read. */
|
|
26
|
+
rows?: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const IDENT = '"(?:[^"]|"")+"|[A-Za-z_][\\w$]*';
|
|
30
|
+
const CREATE_INDEX = new RegExp(
|
|
31
|
+
`^CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?!CONCURRENTLY\\b)(?:IF\\s+NOT\\s+EXISTS\\s+)?(${IDENT})\\s+ON\\s+(?:ONLY\\s+)?((?:${IDENT})(?:\\.(?:${IDENT}))?)`,
|
|
32
|
+
'i',
|
|
33
|
+
);
|
|
34
|
+
const unquote = (part: string): string => (part.startsWith('"') ? part.slice(1, -1).replace(/""/g, '"') : part);
|
|
35
|
+
const qualified = (name: string): string => {
|
|
36
|
+
const parts = name.match(new RegExp(IDENT, 'g')) ?? [name];
|
|
37
|
+
const clean = parts.map(unquote);
|
|
38
|
+
return clean.length === 1 ? `public.${clean[0]}` : clean.join('.');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function indexLockWarnings(statements: readonly string[], live: SchemaSnapshot): LockWarning[] {
|
|
42
|
+
const liveTables = new Set(live.tables.map((t) => t.table));
|
|
43
|
+
const out: LockWarning[] = [];
|
|
44
|
+
for (const statement of statements) {
|
|
45
|
+
const m = CREATE_INDEX.exec(statementBody(statement));
|
|
46
|
+
if (!m) continue;
|
|
47
|
+
const table = qualified(m[2]);
|
|
48
|
+
if (!liveTables.has(table)) continue;
|
|
49
|
+
const estimate = live.rowEstimates?.[table];
|
|
50
|
+
if (estimate === 0) continue;
|
|
51
|
+
out.push({ index: unquote(m[1]), table, ...(estimate === undefined ? {} : { rows: estimate < 0 ? null : estimate }) });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const display = (table: string): string => (table.startsWith('public.') ? table.slice('public.'.length) : table);
|
|
57
|
+
|
|
58
|
+
export function lockLines(warnings: readonly LockWarning[]): string[] {
|
|
59
|
+
return warnings.map((w) => {
|
|
60
|
+
const count = w.rows === undefined ? ''
|
|
61
|
+
: w.rows === null ? ' (row count unknown)'
|
|
62
|
+
: ` (about ${w.rows.toLocaleString('en-US')} ${w.rows === 1 ? 'row' : 'rows'})`;
|
|
63
|
+
return `⚠ LOCK — CREATE INDEX ${w.index} on ${display(w.table)} blocks writes to that table until it is built${count}.`;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
* owned by the lifecycle role stamps the four lifecycle columns and skips the delete. A row that
|
|
9
9
|
* is already deleted keeps its stamps. It refuses to run as a trigger on any other table: a
|
|
10
10
|
* role holding EXECUTE could otherwise attach it to its own table and soft-delete `<t>` rows by id.
|
|
11
|
+
* It hands the batch to a caller that cannot read it: for a top-level DELETE (trigger depth 1) it
|
|
12
|
+
* appends `<JSON-quoted key> <batch>` as a line to the transaction-local setting
|
|
13
|
+
* `everystack.lifecycle_batch`, one line per stamped root. Informational, never a gate: any role
|
|
14
|
+
* can set that name, and the stamp columns stay the truth.
|
|
11
15
|
* - `<t>_lifecycle_guard` (BEFORE INSERT OR UPDATE): refuses any direct write to those columns.
|
|
16
|
+
* - `<t>_lifecycle_delete_when` (BEFORE UPDATE OF deleted_at, live -> deleted), only for
|
|
17
|
+
* lifecycle: { deleteWhen }: refuses the stamp (23514) unless the predicate is TRUE for the row,
|
|
18
|
+
* for a direct delete and a cascade alike. Its predicate rides on its own manifest only.
|
|
12
19
|
* - `<t>_restore(p_id)`: undoes a delete for whoever may delete p_id. A SECURITY DEFINER
|
|
13
20
|
* function owned by the lifecycle role, EXECUTE to exactly the roles with a delete rule. The
|
|
14
21
|
* caller is the SET ROLE role (session_user when none); a role branch passes when the caller
|
|
@@ -18,7 +25,8 @@
|
|
|
18
25
|
* the whole cascade subtree that delete stamped, on p_id's authority alone: the delete took
|
|
19
26
|
* those rows whatever the deleter's rights over them, so its undo must too, or a user's
|
|
20
27
|
* restore would leave the likes others put on their posts deleted for good. It never makes a
|
|
21
|
-
* live row under a deleted parent (see restoreFunction), and it returns counts
|
|
28
|
+
* live row under a deleted parent (see restoreFunction), and it returns counts and the batch it
|
|
29
|
+
* restored (`{ restored, skipped, batch }`; batch null for a legacy row), never ids.
|
|
22
30
|
*
|
|
23
31
|
* A lifecycle parent's hasMany `dependent` cannot ride the foreign key: its DELETE never removes the
|
|
24
32
|
* row, so the FK never fires (relationDeleteActions compiles a lifecycle parent's 'cascade' to
|
|
@@ -92,7 +100,7 @@ export interface LifecycleDeclaration {
|
|
|
92
100
|
edges: LifecycleEdge[];
|
|
93
101
|
}
|
|
94
102
|
export const MANIFEST_VERSION = 1;
|
|
95
|
-
export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent'] as const;
|
|
103
|
+
export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent', 'delete_when'] as const;
|
|
96
104
|
export type LifecycleRole = typeof LIFECYCLE_ROLES[number];
|
|
97
105
|
export interface LifecycleManifest {
|
|
98
106
|
everystack: 'lifecycle';
|
|
@@ -100,6 +108,9 @@ export interface LifecycleManifest {
|
|
|
100
108
|
role: LifecycleRole;
|
|
101
109
|
model: string;
|
|
102
110
|
decl: LifecycleDeclaration;
|
|
111
|
+
/** The `delete_when` function's own input: lifecycle: { deleteWhen }'s predicate text. Carried on
|
|
112
|
+
* that function's manifest only, so a guard added later changes no other function's manifest. */
|
|
113
|
+
deleteWhen?: string;
|
|
103
114
|
defHash: string;
|
|
104
115
|
}
|
|
105
116
|
type TableName = Pick<LifecycleTable, 'schema' | 'table'>;
|
|
@@ -179,7 +190,7 @@ function lifecycleDependents(models: readonly ModelDescriptor[]): ModelDependent
|
|
|
179
190
|
return out.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
180
191
|
}
|
|
181
192
|
|
|
182
|
-
function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration): LifecycleObject[] {
|
|
193
|
+
function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration, deleteWhen?: string): LifecycleObject[] {
|
|
183
194
|
const table = qualified(model);
|
|
184
195
|
const label = labelOf(model);
|
|
185
196
|
const pk = pkOf(model);
|
|
@@ -200,6 +211,7 @@ function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleD
|
|
|
200
211
|
DECLARE
|
|
201
212
|
claims text := pg_catalog.current_setting('request.jwt.claims', true);
|
|
202
213
|
actor text;
|
|
214
|
+
batch uuid := pg_catalog.gen_random_uuid();
|
|
203
215
|
${check ? `${check.declare}\n` : ''}BEGIN
|
|
204
216
|
IF TG_TABLE_SCHEMA <> ${literal(model.schema)} OR TG_TABLE_NAME <> ${literal(model.table)} THEN
|
|
205
217
|
RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = ${literal(
|
|
@@ -212,9 +224,16 @@ ${check ? ` ${readCommittedOnly(model)}\n` : ''} IF lc.claims IS NOT NULL AND
|
|
|
212
224
|
UPDATE ${table}
|
|
213
225
|
SET ${at} = pg_catalog.now(),
|
|
214
226
|
${by} = lc.actor,
|
|
215
|
-
${batch} =
|
|
227
|
+
${batch} = lc.batch,
|
|
216
228
|
${xid} = pg_catalog.txid_current()
|
|
217
229
|
WHERE ${pk} = OLD.${pk} AND ${at} IS NULL;
|
|
230
|
+
-- The batch, for the caller that cannot read it: one line per root of a top-level DELETE,
|
|
231
|
+
-- appended to a transaction-local setting. Informational; the stamp columns are the truth.
|
|
232
|
+
IF FOUND AND pg_catalog.pg_trigger_depth() = 1 THEN
|
|
233
|
+
PERFORM pg_catalog.set_config('everystack.lifecycle_batch',
|
|
234
|
+
pg_catalog.concat_ws(E'\\n', NULLIF(pg_catalog.current_setting('everystack.lifecycle_batch', true), ''),
|
|
235
|
+
pg_catalog.to_jsonb(OLD.${pk}::text)::text || ' ' || lc.batch::text), true);
|
|
236
|
+
END IF;
|
|
218
237
|
${check ? `${check.body}\n` : ''} RETURN NULL;
|
|
219
238
|
END`,
|
|
220
239
|
});
|
|
@@ -254,6 +273,7 @@ END`,
|
|
|
254
273
|
}),
|
|
255
274
|
...cascadeObjects(model, dependents.filter((d) => d.parent === model && d.dependent === 'cascade')),
|
|
256
275
|
...parentLiveObjects(model, dependents.filter((d) => d.child === model)),
|
|
276
|
+
...deleteWhenObjects(model, deleteWhen),
|
|
257
277
|
];
|
|
258
278
|
}
|
|
259
279
|
|
|
@@ -439,7 +459,7 @@ ${parentsLive(m, `lc.r${i}`, 'lc.live := false;').replace(/^/gm, ' ')}
|
|
|
439
459
|
lc.skipped := lc.skipped + lc.n;`).join('\n');
|
|
440
460
|
|
|
441
461
|
const walk = edges.length === 0
|
|
442
|
-
? ` RETURN pg_catalog.jsonb_build_object('restored', 1, 'skipped', 0);`
|
|
462
|
+
? ` RETURN pg_catalog.jsonb_build_object('restored', 1, 'skipped', 0, 'batch', lc.root.b);`
|
|
443
463
|
: ` lc.batch_id := lc.root.b;
|
|
444
464
|
IF lc.batch_id IS NOT NULL THEN
|
|
445
465
|
lc.s0 := ARRAY[${id}];
|
|
@@ -458,7 +478,7 @@ ${pass}
|
|
|
458
478
|
END LOOP;
|
|
459
479
|
${left}
|
|
460
480
|
END IF;
|
|
461
|
-
RETURN pg_catalog.jsonb_build_object('restored', lc.restored, 'skipped', lc.skipped);`;
|
|
481
|
+
RETURN pg_catalog.jsonb_build_object('restored', lc.restored, 'skipped', lc.skipped, 'batch', lc.batch_id);`;
|
|
462
482
|
|
|
463
483
|
return defineFunction(`${model.schema}.${model.table}_restore`, {
|
|
464
484
|
args: [{ name: 'p_id', type: pkType(model) }],
|
|
@@ -582,7 +602,6 @@ function restrictCheck(model: LifecycleTable, dependents: readonly LifecycleDepe
|
|
|
582
602
|
|| ${literal(` live row(s) of ${labelOf(d.child)} still reference it (dependent 'restrict' on ${d.column})`)};
|
|
583
603
|
END IF;`).join('\n');
|
|
584
604
|
const declare = [
|
|
585
|
-
'batch uuid := pg_catalog.gen_random_uuid();',
|
|
586
605
|
'n bigint;',
|
|
587
606
|
...(walk ? ['grew boolean;', ...tables.flatMap((m, i) => [
|
|
588
607
|
`s${i} ${pkType(m)}[] := '{}';`, `f${i} ${pkType(m)}[] := '{}';`, `nf${i} ${pkType(m)}[];`,
|
|
@@ -616,6 +635,52 @@ ${checks}`;
|
|
|
616
635
|
return { declare, body };
|
|
617
636
|
}
|
|
618
637
|
|
|
638
|
+
/**
|
|
639
|
+
* `<t>_lifecycle_delete_when` — lifecycle: { deleteWhen }'s guard. BEFORE UPDATE OF deleted_at on
|
|
640
|
+
* the live -> deleted stamp, so it fires for the row's own stamp (the transition's UPDATE) and for
|
|
641
|
+
* a parent's cascade stamp alike (a cascaded child's BEFORE DELETE transition never runs). The
|
|
642
|
+
* predicate is read with bare column names against the row as it still stands (a BEFORE trigger:
|
|
643
|
+
* the table holds the old values), one primary-key lookup per stamped row. The predicate sits on
|
|
644
|
+
* lines of its own, so a trailing `--` comment in it ends only its own line. Anything but TRUE
|
|
645
|
+
* refuses with 23514 and the constraint name, naming the table and never a row value; the whole
|
|
646
|
+
* statement rolls back, so one guarded child refuses its parent's delete. No role test: only the
|
|
647
|
+
* lifecycle role stamps (the guard), and restore and purge never make this transition.
|
|
648
|
+
*/
|
|
649
|
+
function deleteWhenObjects(model: LifecycleTable, deleteWhen: string | undefined): LifecycleObject[] {
|
|
650
|
+
if (deleteWhen === undefined) return [];
|
|
651
|
+
const name = `${model.schema}.${model.table}_lifecycle_delete_when`;
|
|
652
|
+
const pk = pkOf(model);
|
|
653
|
+
const guard = lifecycleTriggerFunction(name, `
|
|
654
|
+
#variable_conflict use_column
|
|
655
|
+
<<lc>>
|
|
656
|
+
DECLARE
|
|
657
|
+
allowed boolean;
|
|
658
|
+
BEGIN
|
|
659
|
+
${ownTableOnly(model, name)}
|
|
660
|
+
SELECT (
|
|
661
|
+
${deleteWhen}
|
|
662
|
+
) INTO lc.allowed FROM ${qualified(model)} WHERE ${pk} = OLD.${pk};
|
|
663
|
+
IF lc.allowed IS NOT TRUE THEN
|
|
664
|
+
RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = ${literal(`${model.table}_lifecycle_delete_when`)},
|
|
665
|
+
SCHEMA = ${literal(model.schema)}, TABLE = ${literal(model.table)},
|
|
666
|
+
MESSAGE = ${literal(`delete of ${labelOf(model)} refused: the model's deleteWhen does not allow deleting this row`)};
|
|
667
|
+
END IF;
|
|
668
|
+
RETURN NEW;
|
|
669
|
+
END`);
|
|
670
|
+
return [
|
|
671
|
+
guard,
|
|
672
|
+
lifecycleTrigger(`${model.table}_lifecycle_delete_when`, {
|
|
673
|
+
on: model,
|
|
674
|
+
timing: 'before',
|
|
675
|
+
events: ['update'],
|
|
676
|
+
of: [toSnakeCase(LIFECYCLE_COLUMNS[0])],
|
|
677
|
+
forEach: 'row',
|
|
678
|
+
condition: sql`OLD.${AT} IS NULL AND NEW.${AT} IS NOT NULL`,
|
|
679
|
+
execute: guard,
|
|
680
|
+
}),
|
|
681
|
+
];
|
|
682
|
+
}
|
|
683
|
+
|
|
619
684
|
/**
|
|
620
685
|
* `<t>_lifecycle_parent` on a lifecycle child: a live row may not reference a deleted parent of a
|
|
621
686
|
* 'cascade' or 'restrict' relation. Only a lifecycle parent can be deleted and still be there.
|
|
@@ -686,7 +751,7 @@ export function declOf(model: ModelDescriptor, dependents: readonly ModelDepende
|
|
|
686
751
|
}
|
|
687
752
|
|
|
688
753
|
/** One generator for model emission and verification of recorded JSON inputs. */
|
|
689
|
-
export function regenerateLifecycle(model: string, decl: LifecycleDeclaration): LifecycleObject[] {
|
|
754
|
+
export function regenerateLifecycle(model: string, decl: LifecycleDeclaration, deleteWhen?: string): LifecycleObject[] {
|
|
690
755
|
const root = decl.tables[model];
|
|
691
756
|
if (!root) throw new Error(`lifecycle declaration misses ${model}`);
|
|
692
757
|
const dependents = decl.edges.map(edge => {
|
|
@@ -696,7 +761,7 @@ export function regenerateLifecycle(model: string, decl: LifecycleDeclaration):
|
|
|
696
761
|
const { schema, name: table } = parseQualified(edge.child);
|
|
697
762
|
return { parent, child: decl.tables[edge.child] ?? { schema, table }, column: edge.column, dependent: edge.dependent };
|
|
698
763
|
});
|
|
699
|
-
return lifecycleObjects(root, dependents, decl);
|
|
764
|
+
return lifecycleObjects(root, dependents, decl, deleteWhen);
|
|
700
765
|
}
|
|
701
766
|
|
|
702
767
|
/** The generated lifecycle objects, with comments stamped by the ordinary reconciler. */
|
|
@@ -707,12 +772,13 @@ export function lifecycleDerived(models: readonly ModelDescriptor[]): DerivedDes
|
|
|
707
772
|
.sort((a, b) => labelOf(a) < labelOf(b) ? -1 : labelOf(a) > labelOf(b) ? 1 : 0)
|
|
708
773
|
.flatMap(model => {
|
|
709
774
|
const decl = declOf(model, dependents);
|
|
710
|
-
const generated = regenerateLifecycle(labelOf(model), decl);
|
|
775
|
+
const generated = regenerateLifecycle(labelOf(model), decl, model.deleteWhen);
|
|
711
776
|
for (const object of generated) {
|
|
712
777
|
if (object.kind !== 'function') continue;
|
|
713
778
|
const role = lifecycleRole(object.name, labelOf(model));
|
|
714
779
|
if (!role) throw new Error(`unknown lifecycle function ${object.name}`);
|
|
715
|
-
const manifest: LifecycleManifest = { everystack: 'lifecycle', v: MANIFEST_VERSION, role, model: labelOf(model), decl,
|
|
780
|
+
const manifest: LifecycleManifest = { everystack: 'lifecycle', v: MANIFEST_VERSION, role, model: labelOf(model), decl,
|
|
781
|
+
...(role === 'delete_when' && model.deleteWhen !== undefined ? { deleteWhen: model.deleteWhen } : {}), defHash: canonHash(canonOfFunction(object)) };
|
|
716
782
|
object.comment = JSON.stringify(manifest);
|
|
717
783
|
}
|
|
718
784
|
// Only the emitter binds table references back to Models; the builders never see them.
|
|
@@ -22,6 +22,7 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
|
22
22
|
import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
|
|
23
23
|
import type { DerivedCatalog } from './derived-introspect';
|
|
24
24
|
import { compileTableContract } from './authz-compile';
|
|
25
|
+
import { lifecycleFkIndexName } from './schema-compile';
|
|
25
26
|
|
|
26
27
|
/** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
|
|
27
28
|
const GENERATED_COLUMNS: Record<string, string> = {
|
|
@@ -32,6 +33,7 @@ const GENERATED_COLUMNS: Record<string, string> = {
|
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
const GUARD = '(deleted_at IS NULL)';
|
|
36
|
+
const DELETED_ONLY = '(deleted_at IS NOT NULL)';
|
|
35
37
|
|
|
36
38
|
/** The commands the measured effective matrix reports (`effectivePrivileges`). */
|
|
37
39
|
const EFFECTIVE_COMMANDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
|
|
@@ -42,6 +44,8 @@ export interface LifecycleFold {
|
|
|
42
44
|
catalog: DerivedCatalog;
|
|
43
45
|
/** Schema-qualified tables folded to `lifecycle: true`. */
|
|
44
46
|
tables: Set<string>;
|
|
47
|
+
/** lifecycle: { deleteWhen }'s predicate per folded table, read from its delete_when function's manifest. */
|
|
48
|
+
deleteWhen: Map<string, string>;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
const split = (qualified: string): { schema: string; name: string } => {
|
|
@@ -79,8 +83,31 @@ function columnsAreGenerated(table: TableSchema): boolean {
|
|
|
79
83
|
});
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
/** The lifecycle columns a deleted reader is never granted (compileColumnGrants). */
|
|
87
|
+
const DELETED_READ_HIDDEN = new Set(['deleted_by', 'deleted_batch', 'deleted_xid']);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* can('read', { role, deleted }) with no columns compiles to a column SELECT of every column but
|
|
91
|
+
* deleted_by, deleted_batch and deleted_xid. Fold exactly that grant back to the table SELECT the
|
|
92
|
+
* renderer reads as a plain role read, so the pull renders `deleted` without a columns list; the
|
|
93
|
+
* compile turns it back into the same column grant. Any other column set stays as it is.
|
|
94
|
+
*/
|
|
95
|
+
function foldDeletedReadGrants(folded: TableContract, table: TableSchema): void {
|
|
96
|
+
const every = sorted(table.columns.map((c) => c.name).filter((name) => !DELETED_READ_HIDDEN.has(name)));
|
|
97
|
+
for (const p of folded.policies) {
|
|
98
|
+
if (p.deleted === undefined || p.roles.length !== 1) continue;
|
|
99
|
+
const role = p.roles[0];
|
|
100
|
+
const cols = folded.columnGrants?.[role];
|
|
101
|
+
if (!cols || folded.grants[role]?.includes('SELECT') || sorted(cols.SELECT) !== every) continue;
|
|
102
|
+
folded.grants = { ...folded.grants, [role]: [...(folded.grants[role] ?? []), 'SELECT'].sort() };
|
|
103
|
+
const { SELECT: _select, ...rest } = cols;
|
|
104
|
+
const { [role]: _role, ...others } = folded.columnGrants!;
|
|
105
|
+
folded.columnGrants = Object.keys(rest).length ? { ...others, [role]: rest } : others;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
/** The folded contract for one table, or undefined when the table is not exactly lifecycle. */
|
|
83
|
-
function foldContract(live: TableContract): TableContract | undefined {
|
|
110
|
+
function foldContract(live: TableContract, table: TableSchema): TableContract | undefined {
|
|
84
111
|
const role = LIFECYCLE_OWNER_ROLE;
|
|
85
112
|
const expected = reservedRoleContract(live.table);
|
|
86
113
|
if (sorted(live.grants[role]) !== sorted(expected.grants[role])) return undefined;
|
|
@@ -111,8 +138,20 @@ function foldContract(live: TableContract): TableContract | undefined {
|
|
|
111
138
|
continue;
|
|
112
139
|
}
|
|
113
140
|
const rest = unguarded(p.using);
|
|
114
|
-
if (rest
|
|
115
|
-
|
|
141
|
+
if (rest !== undefined) {
|
|
142
|
+
policies.push({ ...p, using: rest ?? 'true' });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// can('read', { role, deleted }): a role's own read policy compiled without the guard
|
|
146
|
+
// ('include') or with (deleted_at IS NOT NULL) in its place ('only'). Any other SELECT policy
|
|
147
|
+
// without the guard leaves the table unfolded, as before.
|
|
148
|
+
const own = p.roles.length === 1 && !['anon', 'authenticated', 'public'].includes(p.roles[0])
|
|
149
|
+
&& p.name === `${split(live.table).name}_select_${p.roles[0]}`;
|
|
150
|
+
if (!own) return undefined;
|
|
151
|
+
if (p.using === DELETED_ONLY) policies.push({ ...p, using: 'true', deleted: 'only' });
|
|
152
|
+
else if (p.using?.startsWith(`(${DELETED_ONLY} AND `) && p.using.endsWith(')')) {
|
|
153
|
+
policies.push({ ...p, using: p.using.slice(DELETED_ONLY.length + 6, -1), deleted: 'only' });
|
|
154
|
+
} else policies.push({ ...p, deleted: 'include' });
|
|
116
155
|
}
|
|
117
156
|
|
|
118
157
|
const { [role]: _grant, ...grants } = live.grants;
|
|
@@ -125,6 +164,7 @@ function foldContract(live: TableContract): TableContract | undefined {
|
|
|
125
164
|
const { [role]: _eff, ...effectivePrivileges } = live.effectivePrivileges;
|
|
126
165
|
folded.effectivePrivileges = effectivePrivileges;
|
|
127
166
|
}
|
|
167
|
+
foldDeletedReadGrants(folded, table);
|
|
128
168
|
return folded;
|
|
129
169
|
}
|
|
130
170
|
|
|
@@ -143,28 +183,62 @@ function generatedIdentities(table: TableSchema): string[] {
|
|
|
143
183
|
`${schema}.${name}_restore(${arg})`,
|
|
144
184
|
`${table.table}.${name}_lifecycle_transition`,
|
|
145
185
|
`${table.table}.${name}_lifecycle_guard`,
|
|
186
|
+
`${schema}.${name}_lifecycle_delete_when()`,
|
|
187
|
+
`${table.table}.${name}_lifecycle_delete_when`,
|
|
146
188
|
];
|
|
147
189
|
}
|
|
148
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The generator's lifecycle FK index (lifecycleDependentIndexes): plain btree, exactly the columns of
|
|
193
|
+
* a foreign key into a folded lifecycle table, under exactly the generated name. Folded like the
|
|
194
|
+
* lifecycle columns, so the pull never renders it as an author index. Anything else stays.
|
|
195
|
+
*/
|
|
196
|
+
function withoutGeneratedFkIndexes(t: TableSchema, lifecycleTables: ReadonlySet<string>): TableSchema {
|
|
197
|
+
const name = t.table.slice(t.table.indexOf('.') + 1);
|
|
198
|
+
const indexes = t.indexes.filter((ix) => !(
|
|
199
|
+
!ix.unique && !ix.where && !ix.using && !ix.include?.length
|
|
200
|
+
&& ix.name === lifecycleFkIndexName(name, ix.columns)
|
|
201
|
+
&& t.foreignKeys.some((fk) => lifecycleTables.has(fk.refTable.includes('.') ? fk.refTable : `public.${fk.refTable}`)
|
|
202
|
+
&& JSON.stringify(fk.columns) === JSON.stringify(ix.columns))));
|
|
203
|
+
return indexes.length === t.indexes.length ? t : { ...t, indexes };
|
|
204
|
+
}
|
|
205
|
+
|
|
149
206
|
export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
|
|
150
207
|
const tables = new Set<string>();
|
|
151
208
|
const foldedContracts = new Map<string, TableContract>();
|
|
152
209
|
for (const table of snapshot.tables) {
|
|
153
210
|
if (table.primaryKey.length !== 1 || !columnsAreGenerated(table)) continue;
|
|
154
211
|
const live = contract.tables.find((t) => t.table === table.table);
|
|
155
|
-
const folded = live && live.rls.enabled ? foldContract(live) : undefined;
|
|
212
|
+
const folded = live && live.rls.enabled ? foldContract(live, table) : undefined;
|
|
156
213
|
if (!folded) continue;
|
|
157
214
|
tables.add(table.table);
|
|
158
215
|
foldedContracts.set(table.table, folded);
|
|
159
216
|
}
|
|
160
|
-
if (tables.size === 0) return { snapshot, contract, catalog, tables };
|
|
217
|
+
if (tables.size === 0) return { snapshot, contract, catalog, tables, deleteWhen: new Map() };
|
|
218
|
+
|
|
219
|
+
// A guarded table's predicate is the delete_when function's manifest input. The pull must render
|
|
220
|
+
// exactly that text, or the next plan would rebuild the guard; without it, refuse rather than drop
|
|
221
|
+
// the guard or render it as an author function.
|
|
222
|
+
const deleteWhen = new Map<string, string>();
|
|
223
|
+
for (const table of tables) {
|
|
224
|
+
const { schema, name } = split(table);
|
|
225
|
+
const fn = catalog.objects.find((o) => o.kind === 'function' && o.identity === `${schema}.${name}_lifecycle_delete_when()`);
|
|
226
|
+
if (!fn) continue;
|
|
227
|
+
let manifest: { role?: unknown; model?: unknown; deleteWhen?: unknown } | undefined;
|
|
228
|
+
try { manifest = typeof fn.comment === 'string' ? JSON.parse(fn.comment) : undefined; } catch { manifest = undefined; }
|
|
229
|
+
if (!manifest || manifest.role !== 'delete_when' || manifest.model !== table || typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') {
|
|
230
|
+
throw new Error(`db:pull: ${fn.identity} carries no readable deleteWhen in its lifecycle manifest, so ${table}'s lifecycle: { deleteWhen } `
|
|
231
|
+
+ `cannot be rendered exactly. Run db:apply from the checkout that declared it (it rewrites the manifest), then pull again.`);
|
|
232
|
+
}
|
|
233
|
+
deleteWhen.set(table, manifest.deleteWhen);
|
|
234
|
+
}
|
|
161
235
|
|
|
162
236
|
const generated = new Set(snapshot.tables.filter((t) => tables.has(t.table)).flatMap(generatedIdentities));
|
|
163
237
|
return {
|
|
164
238
|
snapshot: {
|
|
165
239
|
...snapshot,
|
|
166
|
-
tables: snapshot.tables.map((t) =>
|
|
167
|
-
tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t),
|
|
240
|
+
tables: snapshot.tables.map((t) => withoutGeneratedFkIndexes(
|
|
241
|
+
tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t, tables)),
|
|
168
242
|
},
|
|
169
243
|
contract: { ...contract, tables: contract.tables.map((t) => foldedContracts.get(t.table) ?? t) },
|
|
170
244
|
catalog: {
|
|
@@ -173,5 +247,6 @@ export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract,
|
|
|
173
247
|
provenance: catalog.provenance.filter((p) => !generated.has(p.identity)),
|
|
174
248
|
},
|
|
175
249
|
tables,
|
|
250
|
+
deleteWhen,
|
|
176
251
|
};
|
|
177
252
|
}
|
|
@@ -152,6 +152,8 @@ export async function verifyLifecycle(session: SessionRunner, governedRoles: rea
|
|
|
152
152
|
&& normalizeArgType(live.argTypes[0]) === normalizeArgType(pkTypes[0]);
|
|
153
153
|
});
|
|
154
154
|
const manifests = new Map<string, LifecycleManifest>();
|
|
155
|
+
/** lifecycle: { deleteWhen }'s predicate per model, from its delete_when function's own manifest. */
|
|
156
|
+
const deleteWhenOf = new Map<string, string>();
|
|
155
157
|
const groups = new Map<string, { decl: LifecycleDeclaration; from: string }>();
|
|
156
158
|
for (const [id, live] of liveFunctions) {
|
|
157
159
|
let raw: unknown;
|
|
@@ -169,6 +171,10 @@ export async function verifyLifecycle(session: SessionRunner, governedRoles: rea
|
|
|
169
171
|
|| typeof raw.defHash !== 'string' || !/^[a-f0-9]{64}$/.test(raw.defHash) || !raw.decl.tables[raw.model]) { add(id, 'malformed lifecycle manifest'); continue; }
|
|
170
172
|
if (lifecycleRole(live.name, raw.model) !== raw.role) { add(id, 'manifest role does not match function name'); continue; }
|
|
171
173
|
const manifest = raw as unknown as LifecycleManifest;
|
|
174
|
+
if (manifest.role === 'delete_when') {
|
|
175
|
+
if (typeof manifest.deleteWhen !== 'string' || manifest.deleteWhen.trim() === '') { add(id, 'malformed lifecycle manifest'); continue; }
|
|
176
|
+
deleteWhenOf.set(manifest.model, manifest.deleteWhen);
|
|
177
|
+
}
|
|
172
178
|
manifests.set(id, manifest);
|
|
173
179
|
const prev = groups.get(manifest.model);
|
|
174
180
|
if (prev && JSON.stringify(prev.decl) !== JSON.stringify(manifest.decl)) add(id, `conflicting declarations for ${manifest.model}`);
|
|
@@ -195,7 +201,7 @@ export async function verifyLifecycle(session: SessionRunner, governedRoles: rea
|
|
|
195
201
|
&& same(strings(actual.references), strings(parent?.pk_columns)) && actual.action === 'r')) add(`${edge.child}.${edge.column}`, 'foreign key differs from catalog');
|
|
196
202
|
}
|
|
197
203
|
let objects;
|
|
198
|
-
try { objects = regenerateLifecycle(model, decl); } catch { add(from, 'malformed declaration cannot regenerate'); continue; }
|
|
204
|
+
try { objects = regenerateLifecycle(model, decl, deleteWhenOf.get(model)); } catch { add(from, 'malformed declaration cannot regenerate'); continue; }
|
|
199
205
|
for (const object of objects) {
|
|
200
206
|
if (object.kind === 'function') expectedFunctions.set(canonOfFunction(object).identity, object);
|
|
201
207
|
else { const canon = canonOfTrigger(object); expectedTriggers.set(canon.identity, canon); }
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
|
|
22
|
+
import { lifecycleDependentIndexes, relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
|
|
23
23
|
import type { AuthzContract, TableContract } from './authz-contract';
|
|
24
|
-
import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
|
|
24
|
+
import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, dependentIndexSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
|
|
25
25
|
import { compileTableContract } from './authz-compile';
|
|
26
26
|
import { emitReconcileSql } from './authz-reconcile';
|
|
27
27
|
import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff';
|
|
@@ -48,6 +48,7 @@ export function compileMigration(
|
|
|
48
48
|
// A parent's hasMany `dependent` sets a child FK's ON DELETE; resolved over the whole set
|
|
49
49
|
// from THIS set, before any FK is compiled.
|
|
50
50
|
const dependents = relationDeleteActions(models);
|
|
51
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
51
52
|
|
|
52
53
|
// 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
|
|
53
54
|
// compileModuleMigration has always emitted these; compileMigration had no way to,
|
|
@@ -113,7 +114,7 @@ export function compileMigration(
|
|
|
113
114
|
// schema-qualified table, so a non-public index targets the right table.
|
|
114
115
|
const indexCreates: SchemaChange[] = [];
|
|
115
116
|
for (const model of models) {
|
|
116
|
-
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
117
|
+
for (const ix of [...modelConstraintSpecs(model).indexes, ...dependentIndexSpecs(model, dependentIndexes)]) {
|
|
117
118
|
indexCreates.push({
|
|
118
119
|
kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique,
|
|
119
120
|
...(ix.where ? { where: ix.where } : {}),
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
19
|
-
import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
|
|
19
|
+
import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
|
|
20
20
|
import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
|
|
21
21
|
import type { AuthzContract, SequenceGrantContract } from './authz-contract';
|
|
22
22
|
import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
|
|
@@ -144,12 +144,13 @@ export function generateMigration(
|
|
|
144
144
|
assertExternalTargets(models);
|
|
145
145
|
// hasMany `dependent` → the child FK's ON DELETE, derived from THIS set (and checked, for the same reason).
|
|
146
146
|
const dependents = relationDeleteActions(models);
|
|
147
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
147
148
|
const schema = opts.schema ?? 'public';
|
|
148
149
|
// A table's schema comes from the model first (package tables live in dist/ops/auth),
|
|
149
150
|
// then the call default — so one run spans schemas.
|
|
150
151
|
const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
|
|
151
152
|
const desiredEnums = compileEnums(models);
|
|
152
|
-
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
|
|
153
|
+
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents, dependentIndexes })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
|
|
153
154
|
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
154
155
|
const declaredEnums = new Set(desiredEnums.map((e) => e.name));
|
|
155
156
|
// Scope both tables and enums to what the Models declare — an undeclared table OR enum
|
package/src/cli/model-api.ts
CHANGED
|
@@ -23,6 +23,15 @@ export * from './model-render';
|
|
|
23
23
|
export * from './migration-compile';
|
|
24
24
|
export * from './migration-generate';
|
|
25
25
|
|
|
26
|
+
// --- the DECLARED state of a model set, as introspection would see it -------
|
|
27
|
+
// `compileDeclaredState(models, { schema })` → `{ snapshot, contract }`: the same compilation
|
|
28
|
+
// db:plan, db:check and the fingerprint use. Diff `.snapshot` against `introspectSchema(...)`
|
|
29
|
+
// to verify a database built from the Models. Prefer it to mapping `compileTableSchema` over
|
|
30
|
+
// the set yourself: the set derives table parts no single Model carries (the hasMany ON DELETE,
|
|
31
|
+
// the generated `<table>_<column>_lifecycle_fk_idx` indexes), and a hand-rolled loop that misses
|
|
32
|
+
// one reads the live object as a drop.
|
|
33
|
+
export { compileDeclaredState, type DeclaredState } from './declared-diff';
|
|
34
|
+
|
|
26
35
|
// --- authz layer: contract compile/introspect/diff/reconcile ---------------
|
|
27
36
|
export * from './authz-compile';
|
|
28
37
|
export * from './authz-contract';
|
package/src/cli/model-render.ts
CHANGED
|
@@ -112,6 +112,8 @@ export interface RenderOptions {
|
|
|
112
112
|
externalDerived?: ExternalDerived;
|
|
113
113
|
/** Tables db:pull folded to `lifecycle: true` (lifecycle-pull.ts): rendered with that key, no softDelete line. */
|
|
114
114
|
lifecycleTables?: ReadonlySet<string>;
|
|
115
|
+
/** lifecycle: { deleteWhen }'s predicate per folded table (lifecycle-pull.ts), rendered verbatim. */
|
|
116
|
+
lifecycleDeleteWhen?: ReadonlyMap<string, string>;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
|
@@ -792,7 +794,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
792
794
|
}
|
|
793
795
|
|
|
794
796
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
795
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle = false): string {
|
|
797
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle: boolean | string = false): string {
|
|
796
798
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
797
799
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
798
800
|
const validates = new Map<string, string>();
|
|
@@ -818,7 +820,11 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
818
820
|
// put it, proving the position is mechanical-edit-friendly).
|
|
819
821
|
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
820
822
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
821
|
-
|
|
823
|
+
// A guarded table renders its predicate inside sql`…`, escaped so the template's cooked string
|
|
824
|
+
// is the predicate text exactly (the model stores it verbatim; the generated guard hashes it).
|
|
825
|
+
const softDelete = typeof lifecycle === 'string'
|
|
826
|
+
? ` lifecycle: { deleteWhen: sql\`${lifecycle.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\` },\n`
|
|
827
|
+
: lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
|
|
822
828
|
|
|
823
829
|
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
824
830
|
// qualification cannot ride in the first argument (that would make the table literally
|
|
@@ -945,7 +951,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
945
951
|
const known = knownSet(tables.map((t) => t.table));
|
|
946
952
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
947
953
|
|
|
948
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table)));
|
|
954
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table)));
|
|
949
955
|
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
950
956
|
// reference, before the module wrapper that composes all three.
|
|
951
957
|
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
@@ -1022,7 +1028,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
1022
1028
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
1023
1029
|
|
|
1024
1030
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
1025
|
-
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table));
|
|
1031
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table));
|
|
1026
1032
|
const crossImports = referencedTables(t, known).map(
|
|
1027
1033
|
(target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
|
|
1028
1034
|
);
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* ⚠ ACCESS db:plan only: one summary line when the plan removes a policy, revokes a
|
|
18
18
|
* grant, turns row security off, or removes a function, view or trigger. Not destructive (no --confirm), but such a
|
|
19
19
|
* plan is never called "Safe to apply".
|
|
20
|
+
* ⚠ LOCK db:plan only: one line per plain CREATE INDEX on a live table with rows (it
|
|
21
|
+
* blocks writes while it builds), with the row estimate. Informational: the plan
|
|
22
|
+
* is still "Safe to apply", needs no --confirm and exits the same (index-lock.ts).
|
|
20
23
|
* ⚠ CONSTRAINT db:plan only: one line per primary key the plan replaces, naming the old key.
|
|
21
24
|
* Read from the replacement note the emitter writes from the change itself
|
|
22
25
|
* (schema-diff's parsePrimaryKeyReplacement), never from the DDL. Not
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
|
|
37
40
|
import { statementBody } from './sql-statement';
|
|
38
41
|
import { parsePrimaryKeyReplacement } from './schema-diff';
|
|
42
|
+
import { lockLines, type LockWarning } from './index-lock';
|
|
39
43
|
import type { ReconcileAction, ReconcilePlan } from './derived-plan';
|
|
40
44
|
import type { DerivedKind } from './derived-source';
|
|
41
45
|
|
|
@@ -440,6 +444,10 @@ export interface PlanReportInput {
|
|
|
440
444
|
* Absent, or with nothing that changes an object, the report is byte-identical to one without it.
|
|
441
445
|
*/
|
|
442
446
|
derived?: Pick<ReconcilePlan, 'actions' | 'regrants'>;
|
|
447
|
+
/** The plan's ⚠ LOCK data (EdgePlan.lockWarnings). Informational; absent or empty prints nothing. */
|
|
448
|
+
lockWarnings?: readonly LockWarning[];
|
|
449
|
+
/** The plan's deleted-row reads (EdgePlan.deletedReads): one ⚠ ACCESS line each. */
|
|
450
|
+
deletedReads?: readonly { table: string; role: string }[];
|
|
443
451
|
}
|
|
444
452
|
|
|
445
453
|
// ---------------------------------------------------------------------------
|
|
@@ -617,12 +625,17 @@ export function renderPlanReport(input: PlanReportInput): string[] {
|
|
|
617
625
|
|
|
618
626
|
const keys = constraintLines(executable);
|
|
619
627
|
lines.push(...keys);
|
|
628
|
+
lines.push(...lockLines(input.lockWarnings ?? []));
|
|
620
629
|
const access = accessLine(accessChanges([...executable, ...derived.statements]), derived.removed);
|
|
621
630
|
if (access) lines.push(access);
|
|
631
|
+
// can('read', { deleted }): a role that can now read deleted rows. Not destructive (no --confirm);
|
|
632
|
+
// like every other ACCESS line, the plan is then not called "Safe to apply".
|
|
633
|
+
const deletedReads = (input.deletedReads ?? []).map((d) => `${ACCESS_MARK} — role ${d.role} can read deleted rows of ${d.table}.`);
|
|
634
|
+
lines.push(...deletedReads);
|
|
622
635
|
lines.push(
|
|
623
636
|
destructive.size > 0
|
|
624
637
|
? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
|
|
625
|
-
: access
|
|
638
|
+
: access || deletedReads.length > 0
|
|
626
639
|
? 'Non-destructive. Verify the access changes before applying.'
|
|
627
640
|
: keys.length > 0
|
|
628
641
|
? 'Non-destructive. Verify the replaced keys before applying.'
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions, DependentIndexes } from '@everystack/model';
|
|
23
24
|
import { positionalCheckName } from '@everystack/model';
|
|
24
25
|
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
|
|
25
26
|
import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
|
|
@@ -54,6 +55,33 @@ export function generatedIndexName(table: string, columns: string[]): string {
|
|
|
54
55
|
return `${table}_${parts.join('_')}_index`;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The name of a generated lifecycle FK index: `<table>_<columns>_lifecycle_fk_idx`. The suffix keeps
|
|
60
|
+
* it apart from author names (an author's generated name ends `_index`) and marks it generated, so
|
|
61
|
+
* db:pull folds it. Over PostgreSQL's 63 bytes it keeps 56 and appends `_` and 6 hex of a sha256 of
|
|
62
|
+
* the full name, branch-db's convention, so two long names never truncate to one.
|
|
63
|
+
*/
|
|
64
|
+
export function lifecycleFkIndexName(table: string, columns: readonly string[]): string {
|
|
65
|
+
const name = `${table}_${columns.join('_')}_lifecycle_fk_idx`;
|
|
66
|
+
if (Buffer.byteLength(name) <= 63) return name;
|
|
67
|
+
// 56 BYTES, never inside a character: a multi-byte name cut by characters overran 63 bytes, and
|
|
68
|
+
// PostgreSQL then truncated it to a name the generator never planned. ASCII cuts as before.
|
|
69
|
+
let prefix = '';
|
|
70
|
+
for (const ch of name) {
|
|
71
|
+
if (Buffer.byteLength(prefix + ch) > 56) break;
|
|
72
|
+
prefix += ch;
|
|
73
|
+
}
|
|
74
|
+
return `${prefix}_${createHash('sha256').update(name).digest('hex').slice(0, 6)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The set's generated FK indexes for one model (`lifecycleDependentIndexes(models)`), as IR. */
|
|
78
|
+
export function dependentIndexSpecs(model: ModelDescriptor, dependentIndexes?: DependentIndexes): IndexSchema[] {
|
|
79
|
+
return (dependentIndexes?.get(model) ?? []).map((keys) => {
|
|
80
|
+
const columns = keys.map(toSnakeCase);
|
|
81
|
+
return { name: lifecycleFkIndexName(model.table, columns), columns, unique: false };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
|
|
58
86
|
const uniques: UniqueConstraint[] = [];
|
|
59
87
|
const checks: CheckConstraint[] = [];
|
|
@@ -547,7 +575,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
|
|
|
547
575
|
* structured form for the brownfield diff. They share the field→type/default/check
|
|
548
576
|
* helpers, so they never disagree.)
|
|
549
577
|
*/
|
|
550
|
-
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
|
|
578
|
+
export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions; dependentIndexes?: DependentIndexes } = {}): TableSchema {
|
|
551
579
|
const schema = model.schema ?? opts.schema ?? 'public';
|
|
552
580
|
const entries = Object.entries(model.fields);
|
|
553
581
|
|
|
@@ -607,7 +635,7 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
607
635
|
uniques,
|
|
608
636
|
checks,
|
|
609
637
|
foreignKeys: modelForeignKeys(model, opts.dependents),
|
|
610
|
-
indexes: tableConstraints.indexes,
|
|
638
|
+
indexes: [...tableConstraints.indexes, ...dependentIndexSpecs(model, opts.dependentIndexes)],
|
|
611
639
|
};
|
|
612
640
|
}
|
|
613
641
|
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
import { createHash } from 'node:crypto';
|
|
54
54
|
import { canonicalAuthz } from './authz-canonical';
|
|
55
55
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
56
|
-
import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
|
|
56
|
+
import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
|
|
57
57
|
import type { EnumType, SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
58
58
|
import type { AuthzContract, TableContract, SequenceGrantContract } from './authz-contract';
|
|
59
59
|
import { compileTableSchema, compileEnums, compileSequences } from './schema-compile';
|
|
@@ -498,9 +498,10 @@ export function fingerprintModels(
|
|
|
498
498
|
assertExternalTargets(models);
|
|
499
499
|
// Also over the whole set: a parent's hasMany `dependent` sets the ON DELETE of a child's FK.
|
|
500
500
|
const dependents = relationDeleteActions(models);
|
|
501
|
+
const dependentIndexes = lifecycleDependentIndexes(models);
|
|
501
502
|
const scoped = opts.schemas ? models.filter((m) => opts.schemas!.includes(m.schema)) : models;
|
|
502
503
|
const snapshot: SchemaSnapshot = {
|
|
503
|
-
tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents })),
|
|
504
|
+
tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents, dependentIndexes })),
|
|
504
505
|
enums: compileEnums(scoped),
|
|
505
506
|
...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
|
|
506
507
|
};
|
|
@@ -166,12 +166,35 @@ export interface SchemaSnapshot {
|
|
|
166
166
|
sequences?: SequenceSchema[];
|
|
167
167
|
/** Installed extensions, `plpgsql` excluded. Absent when the read predates this. */
|
|
168
168
|
extensions?: string[];
|
|
169
|
+
/**
|
|
170
|
+
* The catalog's row estimate per table (`pg_class.reltuples`): > 0 an estimate, 0 empty, -1 a
|
|
171
|
+
* table with data that was never analyzed. For the plan's ⚠ LOCK line only. Beside the tables,
|
|
172
|
+
* never inside one, so no fingerprint or diff reads it. Absent when the read predates it.
|
|
173
|
+
*/
|
|
174
|
+
rowEstimates?: Record<string, number>;
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
// ---------------------------------------------------------------------------
|
|
172
178
|
// Introspection SQL.
|
|
173
179
|
// ---------------------------------------------------------------------------
|
|
174
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Row estimates for the plan's ⚠ LOCK line — catalog statistics, no table scan. reltuples is -1
|
|
183
|
+
* before the first VACUUM/ANALYZE; a table that has pages then is reported -1 (data, unknown count).
|
|
184
|
+
*/
|
|
185
|
+
export const ROW_ESTIMATES_SQL = `
|
|
186
|
+
SELECT n.nspname AS schema, c.relname AS "table",
|
|
187
|
+
CASE WHEN c.reltuples > 0 THEN c.reltuples::bigint
|
|
188
|
+
WHEN pg_catalog.pg_relation_size(c.oid) > 0 THEN -1
|
|
189
|
+
ELSE 0 END AS estimate
|
|
190
|
+
FROM pg_class c
|
|
191
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
192
|
+
WHERE c.relkind = 'r'
|
|
193
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
194
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
195
|
+
ORDER BY 1, 2;
|
|
196
|
+
`.trim();
|
|
197
|
+
|
|
175
198
|
/**
|
|
176
199
|
* Every column of every base table, with its precise type (`format_type`),
|
|
177
200
|
* nullability, and default. Extension-owned tables are excluded via pg_depend,
|
|
@@ -711,6 +734,8 @@ export interface SchemaRows {
|
|
|
711
734
|
indexes?: IndexRow[];
|
|
712
735
|
sequences?: SequenceRow[];
|
|
713
736
|
extensions?: ExtensionRow[];
|
|
737
|
+
/** Absent on a read that predates ROW_ESTIMATES_SQL (an older ops Lambda reply). */
|
|
738
|
+
estimates?: Array<{ schema: string; table: string; estimate: unknown }>;
|
|
714
739
|
}
|
|
715
740
|
|
|
716
741
|
/**
|
|
@@ -804,6 +829,9 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
804
829
|
enums,
|
|
805
830
|
...(sequences.length ? { sequences } : {}),
|
|
806
831
|
...(extensions !== undefined ? { extensions } : {}),
|
|
832
|
+
...(rows.estimates !== undefined
|
|
833
|
+
? { rowEstimates: Object.fromEntries(rows.estimates.map((r) => [`${r.schema}.${r.table}`, Number(r.estimate)])) }
|
|
834
|
+
: {}),
|
|
807
835
|
};
|
|
808
836
|
}
|
|
809
837
|
|
|
@@ -820,8 +848,10 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
|
|
|
820
848
|
// search_path. Column defaults, CHECK constraints and index expressions all deparse
|
|
821
849
|
// relative to that path — spread across separate connections these five rows can render
|
|
822
850
|
// the same schema two ways, and a fingerprint minted from the mix describes nothing.
|
|
823
|
-
|
|
824
|
-
|
|
851
|
+
// The row estimates ride the same session and snapshot; a reply without them (fewer result
|
|
852
|
+
// sets) assembles without `rowEstimates`, and the plan then warns without a count.
|
|
853
|
+
const [columns, constraints, enums, indexes, sequences, extensions, estimates] = await session(
|
|
854
|
+
[COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL, ROW_ESTIMATES_SQL],
|
|
825
855
|
INTROSPECTION_SESSION,
|
|
826
856
|
);
|
|
827
857
|
return assembleSchema({
|
|
@@ -831,5 +861,6 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
|
|
|
831
861
|
indexes: indexes as IndexRow[],
|
|
832
862
|
sequences: sequences as SequenceRow[],
|
|
833
863
|
extensions: extensions as ExtensionRow[],
|
|
864
|
+
...(Array.isArray(estimates) ? { estimates: estimates as SchemaRows['estimates'] } : {}),
|
|
834
865
|
});
|
|
835
866
|
}
|