@everystack/cli 0.3.27 → 0.3.31

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.
@@ -8,7 +8,8 @@
8
8
  * overwrite explicit, and the drift stays in the report so the operator sees
9
9
  * what they overwrote). Never-reconciled matches are `needsBaseline`: the
10
10
  * reconciler refuses to guess whether live matches source — `baseline: true`
11
- * records the trust explicitly, once.
11
+ * records the trust explicitly, once, or `rebuild: true` rebuilds them from
12
+ * source (drop+create) so the match is guaranteed rather than asserted.
12
13
  *
13
14
  * Graph work: replacing (or dropping) a relation rebuilds its live transitive
14
15
  * dependents — dropped in reverse-topological order, recreated in source
@@ -72,6 +73,11 @@ export interface ReconcileOptions {
72
73
  baseline?: boolean;
73
74
  /** Rebuild drifted objects from source (the drift finding stays in the report). */
74
75
  overwriteDrift?: boolean;
76
+ /**
77
+ * Rebuild never-reconciled source↔live matches from source (drop+create) instead of trusting
78
+ * them via baseline — the guarantee that live == db/sql when first contact can't verify it.
79
+ */
80
+ rebuild?: boolean;
75
81
  }
76
82
 
77
83
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
@@ -117,8 +123,18 @@ export function planReconcile(
117
123
  continue;
118
124
  }
119
125
  if (!prov) {
120
- if (options.baseline) baseline.push(src.identity);
121
- else needsBaseline.push(src.identity);
126
+ // First contact: no provenance, and src-hash (source text) can't be compared to def-hash
127
+ // (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
128
+ // --rebuild guarantees it by rebuilding from source (relations through the dependency graph
129
+ // below; functions replace in place), and the default flags it for a decision.
130
+ if (options.rebuild) {
131
+ if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
132
+ else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
133
+ } else if (options.baseline) {
134
+ baseline.push(src.identity);
135
+ } else {
136
+ needsBaseline.push(src.identity);
137
+ }
122
138
  continue;
123
139
  }
124
140
 
@@ -423,6 +423,22 @@ export async function invalidateCacheByFunctionName(staleName: string): Promise<
423
423
  }
424
424
  }
425
425
 
426
+ /**
427
+ * Delete the per-stage config cache for one stage, forcing the next stage command to
428
+ * re-discover live. Called after `everystack deploy` (a deploy can add a function the old
429
+ * cache never saw — the Ops function is the canonical case: its name stays absent from a
430
+ * config discovered before it existed, and the 24h TTL keeps that stale config valid, so
431
+ * db ops misroute to the Api function) and by `--refresh`. Best-effort; only touches the
432
+ * `outputs.{stage}.json` cache, never the deploy-written `outputs.json`.
433
+ */
434
+ export async function invalidateCachedConfig(stage: string): Promise<void> {
435
+ try {
436
+ await fs.unlink(cachePath(stage));
437
+ } catch {
438
+ // No cache for this stage — nothing to invalidate.
439
+ }
440
+ }
441
+
426
442
  export async function setCachedConfig(stage: string, config: DiscoveredConfig): Promise<void> {
427
443
  const data: CachedOutputs = {
428
444
  _cachedAt: Date.now(),
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ import { dbApplyCommand } from './commands/db-apply.js';
20
20
  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
+ import { pipelineRunCommand, pipelineListCommand } from './commands/pipeline-run.js';
23
24
  import { dbTemplateRefreshCommand, dbBranchCommand } from './commands/db-branch.js';
24
25
  import { dbForkCommand } from './commands/db-fork.js';
25
26
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
@@ -114,6 +115,14 @@ async function main() {
114
115
  process.exit(0);
115
116
  }
116
117
 
118
+ // `--refresh` forces re-discovery: drop the stale per-stage cache before anything reads it
119
+ // (loadSstOutputs and resolveConfig both consult it). Use after a deploy the CLI didn't run,
120
+ // or whenever a stage command resolves to the wrong function.
121
+ if (flags.refresh && flags.stage) {
122
+ const { invalidateCachedConfig } = await import('./discover.js');
123
+ await invalidateCachedConfig(flags.stage);
124
+ }
125
+
117
126
  // Auto-detect deployed URL from SST outputs
118
127
  await loadSstOutputs(flags.stage);
119
128
 
@@ -210,6 +219,12 @@ async function main() {
210
219
  case 'db:backfill':
211
220
  await dbBackfillCommand(flags);
212
221
  break;
222
+ case 'pipeline:run':
223
+ await pipelineRunCommand(flags);
224
+ break;
225
+ case 'pipeline:list':
226
+ await pipelineListCommand(flags);
227
+ break;
213
228
  case 'db:template:refresh':
214
229
  await dbTemplateRefreshCommand(flags);
215
230
  break;
@@ -342,14 +357,16 @@ Usage:
342
357
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise
343
358
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
344
359
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
345
- everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently); first contact with existing objects wants --baseline, once.
360
+ everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only, atomic — DDL + provenance in one transaction) and records provenance + schema_log. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive.
346
361
  everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
347
362
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
348
363
  everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
349
364
  everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
350
- everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
365
+ everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
351
366
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
352
367
  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
368
+ everystack pipeline:run [--stage <name> | --database-url <url>] [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] [--resume [--run-id <id>]] [--dry-run] [--continue-on-error] [--json] Reproduce data from committed source: run the pipeline's stages in topological order, each idempotent and inside its own transaction, recorded in everystack.pipeline_log. Lanes: --rebuild (automated+frozen) / --curate (curated) / neither (all in order). --resume picks up the latest run, skipping applied stages. With --stage the orchestrator runs credential-free IN the ops Lambda (no URL held; heavy stages refused — run those local); --database-url is local-direct and unbounded
369
+ everystack pipeline:list [--rebuild | --curate] [--only <substr> | --from <id> | --to <id>] Show the pipeline's stages in run order (topological) with their lane and dependency edges — no execution, no database
353
370
  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
354
371
  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
372
  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>
@@ -398,6 +415,9 @@ Stage resolution:
398
415
  --stage <name> Discover AWS resources for the given stage by querying AWS APIs.
399
416
  Uses SST naming convention: {app}-{stage}-{Resource}-{hash}.
400
417
  Results are cached in .sst/outputs.{stage}.json for 24 hours.
418
+ --refresh Drop the cached stage config and re-discover live. Use after a
419
+ deploy the CLI didn't run, or if a stage command resolves to the
420
+ wrong function. \`everystack deploy\` refreshes this cache for you.
401
421
 
402
422
  Without --stage, reads .sst/outputs.json (written by the last \`sst deploy\`).
403
423
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Diagnostics for a crashed ops-Lambda invoke.
3
+ *
4
+ * A consumer's ops handler can fail to *boot* — a broken top-level import (a dependency missing
5
+ * from the deployed bundle, a version skew, a bad path). Lambda then returns a FunctionError with
6
+ * the opaque `Runtime exited with error: exit status 1` for EVERY action, giving the operator no
7
+ * signal that it's the handler failing to start (not their command) and no path to the cause. The
8
+ * CLI turns that into a report naming the real cause by reading the handler's CloudWatch logs; the
9
+ * pure detection/extraction/formatting lives here, the AWS fetch in aws.ts.
10
+ */
11
+
12
+ /**
13
+ * Whether a Lambda FunctionError message is the handler failing to boot / crashing the runtime — an
14
+ * uncaught init error worth chasing into CloudWatch — rather than an already-descriptive error. Only
15
+ * these opaque cases trigger the log fetch; a clear message passes through untouched.
16
+ */
17
+ export function isOpsHandlerCrash(message: string): boolean {
18
+ return /Runtime exited|exited with error|Runtime\.(ImportModuleError|UserCodeSyntaxError|HandlerNotFound)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b/i.test(
19
+ message,
20
+ );
21
+ }
22
+
23
+ /** Log lines worth surfacing: the import error, a runtime error class, or a thrown Error/stack. */
24
+ const SIGNAL = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Runtime\.\w+|(?:Reference|Type|Syntax|Range)?Error:/;
25
+
26
+ /**
27
+ * Pull the diagnostic lines from raw CloudWatch log text for a crashed ops invoke — the import
28
+ * error / stack frame that names the missing dependency — so an opaque "exit status 1" becomes the
29
+ * real cause. De-duplicates and caps output. Returns null when nothing diagnostic is present.
30
+ */
31
+ export function extractCrashDiagnosis(logText: string): string | null {
32
+ const seen = new Set<string>();
33
+ const out: string[] = [];
34
+ for (const raw of logText.split('\n')) {
35
+ const line = raw.trim();
36
+ if (!line || !SIGNAL.test(line) || seen.has(line)) continue;
37
+ seen.add(line);
38
+ out.push(line);
39
+ if (out.length >= 6) break;
40
+ }
41
+ return out.length ? out.join('\n') : null;
42
+ }
43
+
44
+ /**
45
+ * Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
46
+ * handler, not your command" framing, and either the real cause from CloudWatch or a where-to-look
47
+ * hint when the logs couldn't be read.
48
+ */
49
+ export function formatOpsCrashReport(
50
+ baseMessage: string,
51
+ logGroup?: string,
52
+ diagnosis?: string | null,
53
+ ): string {
54
+ const lines = [
55
+ baseMessage,
56
+ '',
57
+ 'The ops handler crashed at startup — usually a missing dependency in the deployed bundle, not a problem with this command.',
58
+ ];
59
+ if (diagnosis) {
60
+ lines.push(
61
+ '',
62
+ `From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
63
+ diagnosis,
64
+ '',
65
+ 'Fix the import/dependency and redeploy.',
66
+ );
67
+ } else {
68
+ lines.push('Check its CloudWatch logs for the failing top-level import, then redeploy.');
69
+ }
70
+ return lines.join('\n');
71
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * pipeline-loader — the CLI's window onto @everystack/server/pipeline.
3
+ *
4
+ * The CLI keeps NO compile-time dependency on @everystack/server (the two are
5
+ * mutual peers; hard-importing would be a project-reference cycle — the same
6
+ * reason plugin.ts inlines its types). So the orchestrator's types are mirrored
7
+ * structurally here and the runtime module is loaded lazily via a variable
8
+ * specifier. Every CLI verb that reaches the pipeline framework (pipeline:run,
9
+ * and the db:sync / db:plan visibility) goes through this one door.
10
+ */
11
+
12
+ export type PipelineLane = 'rebuild' | 'curate' | 'all';
13
+
14
+ export interface PipelineStage {
15
+ id: string;
16
+ lane: 'automated' | 'curated' | 'frozen';
17
+ dependsOn?: string[];
18
+ after?: string[];
19
+ heavy?: boolean;
20
+ }
21
+ export interface Pipeline {
22
+ stages: PipelineStage[];
23
+ }
24
+ export interface RunPipelineOptions {
25
+ runId: string;
26
+ lane?: PipelineLane;
27
+ only?: string;
28
+ from?: string;
29
+ to?: string;
30
+ resume?: boolean;
31
+ dryRun?: boolean;
32
+ continueOnError?: boolean;
33
+ actor?: string | null;
34
+ gitRef?: string | null;
35
+ }
36
+ export interface StageRunResult {
37
+ stageId: string;
38
+ outcome: 'applied' | 'skipped' | 'failed' | 'dry-run';
39
+ rows?: number;
40
+ error?: string;
41
+ }
42
+ export interface PipelineRunOutcome {
43
+ runId: string;
44
+ plan: string[];
45
+ results: StageRunResult[];
46
+ failed: boolean;
47
+ }
48
+ export interface PipelineRunSummary {
49
+ runId: string;
50
+ at: string | null;
51
+ applied: number;
52
+ failed: number;
53
+ failedStages: string[];
54
+ }
55
+
56
+ export interface ServerPipeline {
57
+ runPipeline(runner: unknown, pipeline: Pipeline, options: RunPipelineOptions): Promise<PipelineRunOutcome>;
58
+ latestRunId(query: (sql: string) => Promise<any[]>): Promise<string | null>;
59
+ readLastRun(query: (sql: string) => Promise<any[]>): Promise<PipelineRunSummary | null>;
60
+ topoSortStages(stages: PipelineStage[]): PipelineStage[];
61
+ selectStages(ordered: PipelineStage[], opts: Record<string, unknown>): PipelineStage[];
62
+ }
63
+
64
+ /** Lazily load the orchestrator from the @everystack/server peer (runtime only). */
65
+ export async function loadServerPipeline(): Promise<ServerPipeline> {
66
+ const spec = '@everystack/server/pipeline';
67
+ return (await import(spec)) as ServerPipeline;
68
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * pipeline-path — where the consumer's pipeline definition lives.
3
+ *
4
+ * Beside the schema homes (`db/models/`, `db/sql/`, `db/backfills/`), the
5
+ * pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
6
+ * exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
7
+ * always wins; when neither home exists the primary is returned so error
8
+ * messages point at the current convention.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /** Candidate pipeline entrypoints, in preference order. */
14
+ export const PIPELINE_HOMES = ['db/pipeline.ts', 'db/pipeline/index.ts'] as const;
15
+
16
+ export function resolvePipelinePath(
17
+ flag?: string,
18
+ exists: (p: string) => boolean = existsSync,
19
+ ): string {
20
+ if (flag) return flag;
21
+ for (const home of PIPELINE_HOMES) {
22
+ if (exists(home)) return home;
23
+ }
24
+ return PIPELINE_HOMES[0];
25
+ }
@@ -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
- export const FINGERPRINT_VERSION = 1;
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
- table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: c.default })),
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
- checks: byKey(table.checks.map((c) => ({ expr: c.expr })), (c) => c.expr),
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
- table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: i.where } : {}) })),
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
  };