@everystack/cli 0.3.25 → 0.3.28
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/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/commands/db-fork.ts +139 -0
- package/src/cli/commands/db-sync.ts +11 -6
- package/src/cli/commands/runbook.ts +5 -4
- package/src/cli/index.ts +5 -0
- package/src/cli/runbook/sections.ts +23 -10
- package/src/cli/schema-fingerprint.ts +20 -4
package/README.md
CHANGED
|
@@ -226,6 +226,7 @@ everystack db:approvers # declare who can DESTROY: the stage's d
|
|
|
226
226
|
everystack db:backfill # one-shot data jobs in db/backfills/*.sql — content-hash identity, own per-DB record, never a schema side effect
|
|
227
227
|
everystack db:template:refresh # (re)build the dev template from the declared state + your db:seed script — never a data copy
|
|
228
228
|
everystack db:branch # per-branch dev DB minted from the template, keyed on the git branch (--list / --prune --confirm)
|
|
229
|
+
everystack db:fork # fork a deployed stage's DB into a feature stage (backup → presigned hand-off → restore); production is never a target
|
|
229
230
|
|
|
230
231
|
# Security
|
|
231
232
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
package/package.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:fork` — fork one stage's database into another
|
|
3
|
+
* (branch-stages plan, brick 2; docs/plans/branch-stages.md).
|
|
4
|
+
*
|
|
5
|
+
* db:fork --from-stage staging --stage feat-foo --confirm [--backup <id>]
|
|
6
|
+
*
|
|
7
|
+
* Three moves, all existing surfaces: back up the source (or reuse a named
|
|
8
|
+
* backup), presign the dump with the OPERATOR's credentials — the presigned
|
|
9
|
+
* URL is the whole cross-stage hand-off, no bucket policies, no IAM
|
|
10
|
+
* coupling, expires in an hour — and invoke the target's `db:restore` with
|
|
11
|
+
* the URL. The target stage must already be deployed (`sst deploy --stage
|
|
12
|
+
* feat-foo`); the fork replaces its database with the source's data, and
|
|
13
|
+
* the branch's own schema changes then land through the front door
|
|
14
|
+
* (`db:plan` → `db:apply` — the forked state is one a main commit declares,
|
|
15
|
+
* so the fast-forward rule composes for free).
|
|
16
|
+
*
|
|
17
|
+
* Production is never a fork target, flat — that is disaster recovery
|
|
18
|
+
* (`db:restore`), not a fork. Forking FROM production carries the PII
|
|
19
|
+
* data-governance warning. Teardown is the stage's own: `sst remove`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
23
|
+
import { invokeAction, presignGet } from '../aws.js';
|
|
24
|
+
import { keyForId, parseBackupRef, isProductionTier, crossStageGuard } from '../backup.js';
|
|
25
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
26
|
+
|
|
27
|
+
export type ForkGuardVerdict =
|
|
28
|
+
| { ok: true; piiWarning: string | null }
|
|
29
|
+
| { ok: false; reason: string };
|
|
30
|
+
|
|
31
|
+
/** The pure gate — see the module docblock for the decisions it enforces. */
|
|
32
|
+
export function checkForkGuards(input: { from?: string; stage?: string; confirm: boolean }): ForkGuardVerdict {
|
|
33
|
+
const { from, stage, confirm } = input;
|
|
34
|
+
if (!from || from === 'true') {
|
|
35
|
+
return { ok: false, reason: 'db:fork needs the source: --from-stage <name> (the stage whose data you are forking).' };
|
|
36
|
+
}
|
|
37
|
+
if (!stage || stage === 'true') {
|
|
38
|
+
return { ok: false, reason: 'db:fork needs the target: --stage <name> (an already-deployed feature stage; `sst deploy --stage <name>` first).' };
|
|
39
|
+
}
|
|
40
|
+
if (from === stage) {
|
|
41
|
+
return { ok: false, reason: 'source and target are the same stage — restoring a stage onto itself is db:restore, not a fork.' };
|
|
42
|
+
}
|
|
43
|
+
if (isProductionTier(stage)) {
|
|
44
|
+
return { ok: false, reason: `"${stage}" is production-tier — a fork never overwrites production. Restoring production is disaster recovery: db:restore, with its own ceremony.` };
|
|
45
|
+
}
|
|
46
|
+
if (!confirm) {
|
|
47
|
+
return { ok: false, reason: `db:fork REPLACES ${stage}'s database with ${from}'s data — confirm with --confirm.` };
|
|
48
|
+
}
|
|
49
|
+
const pii = crossStageGuard(from, stage);
|
|
50
|
+
return { ok: true, piiWarning: pii.requiresConfirm ? (pii.warning ?? null) : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function dbForkCommand(flags: Record<string, string>): Promise<void> {
|
|
54
|
+
const verdict = checkForkGuards({
|
|
55
|
+
from: flags['from-stage'],
|
|
56
|
+
stage: flags.stage,
|
|
57
|
+
confirm: flags.confirm === 'true',
|
|
58
|
+
});
|
|
59
|
+
if (!verdict.ok) {
|
|
60
|
+
fail(verdict.reason);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (verdict.piiWarning) warn(verdict.piiWarning.replace(/pass --confirm to proceed\.?$/i, 'confirmed.'));
|
|
64
|
+
|
|
65
|
+
const from = flags['from-stage'];
|
|
66
|
+
const target = flags.stage;
|
|
67
|
+
|
|
68
|
+
step(`Resolving source stage ${from}...`);
|
|
69
|
+
let source: CliConfig;
|
|
70
|
+
try {
|
|
71
|
+
source = await resolveConfig(from);
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
fail(err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (!source.backupsBucket) {
|
|
77
|
+
fail(`Stage ${from} has no backupsBucket in its deployed config — logical backups are the fork's transport (docs/backups.md).`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Move 1: a backup of the source — fresh by default, or a named one.
|
|
82
|
+
let backupId = flags.backup;
|
|
83
|
+
if (backupId && backupId !== 'true') {
|
|
84
|
+
const ref = parseBackupRef(backupId);
|
|
85
|
+
if (!ref || !keyForId(backupId)) {
|
|
86
|
+
fail(`Malformed backup id: ${backupId}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
if (ref.stage !== from) {
|
|
90
|
+
fail(`Backup ${backupId} belongs to stage "${ref.stage}", not --from-stage ${from} — a fork's data names its source honestly.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
info(`Reusing backup ${backupId}.`);
|
|
94
|
+
} else {
|
|
95
|
+
step(`Backing up ${from} (pg_dump → S3; may take a while for large databases)...`);
|
|
96
|
+
const result: any = await invokeAction(source.region, opsFunction(source), 'db:backup', { stage: from });
|
|
97
|
+
if (result?.error) {
|
|
98
|
+
fail(`Source backup failed: ${result.error}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
backupId = result.id;
|
|
102
|
+
info(`Backup ${backupId}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Move 2: the presigned hand-off — the operator's credentials ARE the authorization.
|
|
106
|
+
step('Presigning the dump (1 hour)...');
|
|
107
|
+
let url: string;
|
|
108
|
+
try {
|
|
109
|
+
url = await presignGet(source.region, source.backupsBucket, keyForId(backupId)!, 3600);
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
fail(`Presign failed: ${err.message}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Move 3: restore into the target over the URL.
|
|
116
|
+
step(`Resolving target stage ${target}...`);
|
|
117
|
+
let targetConfig: CliConfig;
|
|
118
|
+
try {
|
|
119
|
+
targetConfig = await resolveConfig(target);
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
fail(`${err.message}\nDeploy the feature stage first: sst deploy --stage ${target}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
step(`Restoring into ${target} (streamed; replaces its database)...`);
|
|
125
|
+
const restored: any = await invokeAction(targetConfig.region, opsFunction(targetConfig), 'db:restore', {
|
|
126
|
+
url,
|
|
127
|
+
confirm: true,
|
|
128
|
+
});
|
|
129
|
+
if (restored?.error) {
|
|
130
|
+
fail(`Restore failed: ${restored.error}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
success(`Forked ${from} → ${target} (backup ${backupId}).`);
|
|
135
|
+
info('Next:');
|
|
136
|
+
info(` everystack db:fingerprint --stage ${target} # where the fork is (a state some main commit declares)`);
|
|
137
|
+
info(` everystack db:plan --stage ${target} # your branch's edge on top — descent composes for free`);
|
|
138
|
+
info(` sst remove --stage ${target} # teardown takes the database with it`);
|
|
139
|
+
}
|
|
@@ -169,6 +169,16 @@ export function buildSyncReport(run: SyncRun): string[] {
|
|
|
169
169
|
lines.push(`· ${state.classified.notices.length} notice(s) — manual follow-ups, nothing executed for them`);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// The base fingerprint is a STATE-layer fact: both endpoints are sampled around
|
|
173
|
+
// the state apply, before compute runs. Print it here, adjacent to the state
|
|
174
|
+
// section, so an empty→full move never reads as if the compute step caused it —
|
|
175
|
+
// the derived layer (functions/views/matviews) is not fingerprinted at all.
|
|
176
|
+
lines.push(
|
|
177
|
+
state.toFingerprint === state.fromFingerprint
|
|
178
|
+
? `base fingerprint: ${short(state.toFingerprint)} (unchanged)`
|
|
179
|
+
: `base fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)} (across the state apply)`,
|
|
180
|
+
);
|
|
181
|
+
|
|
172
182
|
// Compute.
|
|
173
183
|
if (compute === null) {
|
|
174
184
|
lines.push('· compute: no db/sql directory — derived layer not declared, skipped');
|
|
@@ -181,12 +191,7 @@ export function buildSyncReport(run: SyncRun): string[] {
|
|
|
181
191
|
}
|
|
182
192
|
}
|
|
183
193
|
|
|
184
|
-
//
|
|
185
|
-
lines.push(
|
|
186
|
-
state.toFingerprint === state.fromFingerprint
|
|
187
|
-
? `fingerprint: ${short(state.toFingerprint)} (unchanged)`
|
|
188
|
-
: `fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)}`,
|
|
189
|
-
);
|
|
194
|
+
// Verdict.
|
|
190
195
|
if (run.fingerprintMatch) {
|
|
191
196
|
lines.push('MATCH — the database is the state your checkout declares');
|
|
192
197
|
} else if (run.stateConverged) {
|
|
@@ -36,9 +36,10 @@ function cliVersion(): string {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Import the app's Model barrel for the data-model chapter. Absent or unloadable → honest warning, no chapter. */
|
|
39
|
-
async function loadDataModel(root: string, hasIndex: boolean): Promise<{ dataModel: string | null; warning: string | null }> {
|
|
40
|
-
if (!hasIndex) return { dataModel: null, warning: null };
|
|
41
|
-
|
|
39
|
+
async function loadDataModel(root: string, models: { dir: string | null; hasIndex: boolean }): Promise<{ dataModel: string | null; warning: string | null }> {
|
|
40
|
+
if (!models.hasIndex || !models.dir) return { dataModel: null, warning: null };
|
|
41
|
+
// The detected home (db/models preferred, legacy models/ fallback) — never hardcoded.
|
|
42
|
+
const barrel = path.join(root, models.dir, 'index.ts');
|
|
42
43
|
try {
|
|
43
44
|
const mod = await import(pathToFileURL(barrel).href);
|
|
44
45
|
const models = mod.models ?? mod.default;
|
|
@@ -64,7 +65,7 @@ export interface RunbookRun {
|
|
|
64
65
|
/** Result-producing core, shared by the command, `--check`, and the `audit` capstone. */
|
|
65
66
|
export async function runRunbook(root: string): Promise<RunbookRun> {
|
|
66
67
|
const reality = detectProjectReality(root);
|
|
67
|
-
const { dataModel, warning } = await loadDataModel(reality.root, reality.models
|
|
68
|
+
const { dataModel, warning } = await loadDataModel(reality.root, reality.models);
|
|
68
69
|
const outPath = path.join(reality.root, OUT_REL);
|
|
69
70
|
const oldText = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : null;
|
|
70
71
|
const result = generateRunbook(reality, { cliVersion: cliVersion(), dataModel }, oldText ?? undefined);
|
package/src/cli/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { dbCheckCommand } from './commands/db-check.js';
|
|
|
21
21
|
import { dbApproversCommand } from './commands/db-approvers.js';
|
|
22
22
|
import { dbBackfillCommand } from './commands/db-backfill.js';
|
|
23
23
|
import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
|
|
24
|
+
import { dbForkCommand } from './commands/db-fork.js';
|
|
24
25
|
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
25
26
|
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
26
27
|
import { consoleCommand } from './commands/console.js';
|
|
@@ -215,6 +216,9 @@ async function main() {
|
|
|
215
216
|
case 'db:branch':
|
|
216
217
|
await dbBranchCommand(flags);
|
|
217
218
|
break;
|
|
219
|
+
case 'db:fork':
|
|
220
|
+
await dbForkCommand(flags);
|
|
221
|
+
break;
|
|
218
222
|
case 'db:authz:pull':
|
|
219
223
|
await dbAuthzPullCommand(flags);
|
|
220
224
|
break;
|
|
@@ -348,6 +352,7 @@ Usage:
|
|
|
348
352
|
everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
|
|
349
353
|
everystack db:template:refresh [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed] (Re)build the dev template <base>_tpl FROM THE DECLARED STATE (both layers, fingerprint MATCH bar) + seed-as-code (the app's db:seed script, run with DATABASE_URL pointed at the template; --seed overrides). All or nothing: a failed build/seed leaves NO template. Never a data copy
|
|
350
354
|
everystack db:branch [--list | --drop --confirm | --prune --confirm] [--database-url <url>] Mint (or find) the current git branch's database from the template (CREATE DATABASE … TEMPLATE — schema, authz, derived layer, and seed rows inherited), print its DATABASE_URL, then db:sync evolves it with the checkout. --list maps every branch DB to its branch; --prune drops the ones whose branch is gone (never guesses: unknown mappings are kept)
|
|
355
|
+
everystack db:fork --from-stage <src> --stage <target> --confirm [--backup <id>] Fork one DEPLOYED stage's database into another: back up the source (or reuse --backup <id>), presign the dump (the operator's credentials ARE the cross-stage authorization; expires in 1h), restore into the target via its ops Lambda. Production is never a target (that's db:restore); forking FROM production warns about PII; the branch's schema edge then lands via db:plan → db:apply (descent composes). Teardown: sst remove --stage <target>
|
|
351
356
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
352
357
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
353
358
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
|
@@ -150,9 +150,14 @@ function extend(r: ProjectReality): Block {
|
|
|
150
150
|
];
|
|
151
151
|
if (r.models.dir) {
|
|
152
152
|
lines.push(
|
|
153
|
-
|
|
154
|
-
'
|
|
155
|
-
' migration, never edit
|
|
153
|
+
`- **Add a table or field:** edit the Model in \`${r.models.dir}/\`, run \`everystack db:sync\` —`,
|
|
154
|
+
' the dev database follows your checkout (state + authz + derived, verified by fingerprint).',
|
|
155
|
+
' Never hand-write a SQL migration, never edit generated schema — CI runs `everystack db:check`',
|
|
156
|
+
' and refuses artifacts that do not match regeneration.',
|
|
157
|
+
'- **Ship a schema change:** protected stages take plans, not syncs: `everystack db:plan`',
|
|
158
|
+
' (a reviewable edge, fingerprint-pinned at both ends) → review → `everystack db:apply`.',
|
|
159
|
+
'- **Move data:** one-shot jobs in `db/backfills/*.sql`, run deliberately via',
|
|
160
|
+
' `everystack db:backfill --apply` — never as a schema side effect.',
|
|
156
161
|
);
|
|
157
162
|
if (r.migrationMode === 'transitional') {
|
|
158
163
|
lines.push(
|
|
@@ -165,8 +170,9 @@ function extend(r: ProjectReality): Block {
|
|
|
165
170
|
);
|
|
166
171
|
} else if (r.hasDatabase) {
|
|
167
172
|
lines.push(
|
|
168
|
-
'- **Schema changes:** migrate to Models (`everystack db:pull` writes `models/` from the',
|
|
169
|
-
' live database), then the
|
|
173
|
+
'- **Schema changes:** migrate to Models (`everystack db:pull` writes `db/models/` from the',
|
|
174
|
+
' live database — COMMIT that state before evolving it), then the loop is: edit Model →',
|
|
175
|
+
' `everystack db:sync` (dev) / `db:plan` → `db:apply` (protected stages).',
|
|
170
176
|
);
|
|
171
177
|
}
|
|
172
178
|
if (r.handlers.api) {
|
|
@@ -193,17 +199,24 @@ function extend(r: ProjectReality): Block {
|
|
|
193
199
|
function database(r: ProjectReality): Block {
|
|
194
200
|
const flow =
|
|
195
201
|
r.migrationMode === 'everystack'
|
|
196
|
-
? [
|
|
202
|
+
? [
|
|
203
|
+
'- `everystack db:sync` — dev edit loop: the database follows your checkout (state + authz + derived)',
|
|
204
|
+
'- `everystack db:check` — the CI gate: merged Models compose, generated artifacts match regeneration',
|
|
205
|
+
'- `everystack db:plan` → `everystack db:apply` — protected stages: reviewable, fingerprint-verified edges',
|
|
206
|
+
'- `everystack db:branch` / `db:template:refresh` — per-git-branch dev databases from a seeded template',
|
|
207
|
+
'- `everystack db:backfill` — one-shot data jobs (`db/backfills/*.sql`), own record, run deliberately',
|
|
208
|
+
]
|
|
197
209
|
: r.migrationMode === 'transitional'
|
|
198
210
|
? [
|
|
199
211
|
'- **Mid-migration to Models:** the journal is still hand-authored. Target flow is',
|
|
200
|
-
' `everystack db:
|
|
201
|
-
'
|
|
212
|
+
' `everystack db:sync` (dev) / `db:plan` → `db:apply` (protected); `everystack db:generate`',
|
|
213
|
+
' must produce a clean, empty data diff before the generated flow takes over. Until then,',
|
|
214
|
+
' review hand-written SQL like the liability it is.',
|
|
202
215
|
]
|
|
203
216
|
: [
|
|
204
217
|
'- Migrations are hand-authored (drizzle-kit). The Models on-ramp: `everystack db:pull`',
|
|
205
|
-
' writes `models/` from the live database
|
|
206
|
-
'
|
|
218
|
+
' writes `db/models/` from the live database (COMMIT that state before evolving it); from',
|
|
219
|
+
' there `everystack db:sync` / `db:plan` → `db:apply` take over and hand-written SQL retires.',
|
|
207
220
|
];
|
|
208
221
|
return gen('database', [
|
|
209
222
|
'## Database operations',
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
* - FUNCTIONS are excluded — they belong to the derived layer, whose own
|
|
28
28
|
* content hashes (derived-introspect) cover them. Base fingerprint + derived
|
|
29
29
|
* manifest together are the full schema state.
|
|
30
|
+
* - Expressions (column defaults, check predicates, partial-index WHERE) hash
|
|
31
|
+
* through the SAME normalizers the diff uses (`normalizeDefault` /
|
|
32
|
+
* `normalizeCheck`), applied to both sides in `canonicalizeState`. Without
|
|
33
|
+
* this the model side hashes its source form (`'primary'`, `scope = ANY(...)`)
|
|
34
|
+
* while the live side hashes pg's deparse (`'primary'::text`,
|
|
35
|
+
* `(scope = ANY(...))`) — the identity below breaks on any schema with a text
|
|
36
|
+
* default, a check, or a partial index even when the diff is a no-op.
|
|
30
37
|
* - Deparse stability caveat, stated once: policy/check predicates hash as
|
|
31
38
|
* Postgres deparses them; a major-version deparser change can shift
|
|
32
39
|
* fingerprints. The version prefix plus the re-diff path keep that honest.
|
|
@@ -43,8 +50,12 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
|
|
|
43
50
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
44
51
|
import { compileTableSchema, compileEnums } from './schema-compile.js';
|
|
45
52
|
import { compileTableContract } from './authz-compile.js';
|
|
53
|
+
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
46
54
|
|
|
47
|
-
|
|
55
|
+
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
56
|
+
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
57
|
+
// deparsed form agree — the identity `fingerprintModels === fingerprintLive` holds.
|
|
58
|
+
export const FINGERPRINT_VERSION = 2;
|
|
48
59
|
|
|
49
60
|
// ---------------------------------------------------------------------------
|
|
50
61
|
// Canonical form.
|
|
@@ -70,13 +81,17 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
|
|
|
70
81
|
return {
|
|
71
82
|
table: table.table,
|
|
72
83
|
columns: byKey(
|
|
73
|
-
|
|
84
|
+
// Defaults hash SEMANTICALLY, through the same normalizer the diff uses: the Model
|
|
85
|
+
// emits `'primary'`, pg deparses it to `'primary'::text` — the same state, one hash.
|
|
86
|
+
table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
|
|
74
87
|
(c) => c.name,
|
|
75
88
|
),
|
|
76
89
|
primaryKey: table.primaryKey,
|
|
77
90
|
// Content, not names: a pg-default `_key` and a model-named unique are the same state.
|
|
78
91
|
uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
|
|
79
|
-
|
|
92
|
+
// Predicates hash as the diff compares them — one layer of pg's wrapping parens dropped
|
|
93
|
+
// (`(scope = ANY (...))` == `scope = ANY (...)`), whitespace collapsed.
|
|
94
|
+
checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
|
|
80
95
|
foreignKeys: byKey(
|
|
81
96
|
table.foreignKeys.map((f) => ({
|
|
82
97
|
columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
|
|
@@ -86,7 +101,8 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
|
|
|
86
101
|
(f) => stableStringify(f),
|
|
87
102
|
),
|
|
88
103
|
indexes: byKey(
|
|
89
|
-
|
|
104
|
+
// Partial-index WHERE is a predicate too — normalize it the same way as a check.
|
|
105
|
+
table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: normalizeCheck(i.where) } : {}) })),
|
|
90
106
|
(i) => stableStringify(i),
|
|
91
107
|
),
|
|
92
108
|
};
|