@everystack/cli 0.4.30 → 0.4.32

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.
@@ -18,7 +18,7 @@
18
18
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
19
19
  import type { SchemaSnapshot } from './schema-introspect.js';
20
20
  import type { AuthzContract } from './authz-contract.js';
21
- import { compileTableSchema, compileRenames, compileTableRenames, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
21
+ import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
22
22
  import { compileTableContract } from './authz-compile.js';
23
23
  import { emitReconcileSql } from './authz-reconcile.js';
24
24
  import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
@@ -84,11 +84,16 @@ function holdDrop(sql: string): string {
84
84
  export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
85
85
  const schema = opts.schema ?? 'public';
86
86
  const declared = new Set(models.map((m) => `${schema}.${m.table}`));
87
- // A pending table rename's source is ours — declared under its new name.
87
+ // A pending table rename's OR move's source is ours — declared under its new (qualified)
88
+ // name; without this it reads as an undeclared orphan (F1) and the move/rename silently
89
+ // degrades to CREATE + leave-behind, the exact bug the markers exist to prevent.
88
90
  const currentNames = new Set(current.tables.map((t) => t.table));
89
91
  for (const [to, from] of Object.entries(compileTableRenames(models, { schema }))) {
90
92
  if (!currentNames.has(to)) declared.add(from);
91
93
  }
94
+ for (const [to, from] of Object.entries(compileTableMoves(models, { schema }))) {
95
+ if (!currentNames.has(to)) declared.add(from);
96
+ }
92
97
  return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
93
98
  }
94
99
 
@@ -131,20 +136,28 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
131
136
  .map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}"`);
132
137
 
133
138
  const tableRenames = compileTableRenames(models, { schema });
139
+ const tableMoves = compileTableMoves(models, { schema });
134
140
  const currentNames = new Set(current.tables.map((t) => t.table));
135
141
  const pendingRenameSources = new Set(
136
142
  Object.entries(tableRenames)
137
143
  .filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
138
144
  .map(([, from]) => from),
139
145
  );
146
+ // A move's source (old schema, same name) must survive the declared-scope filter too —
147
+ // else the diff's pre-pass never sees it and the move degrades to CREATE + orphan (F1).
148
+ const pendingMoveSources = new Set(
149
+ Object.entries(tableMoves)
150
+ .filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
151
+ .map(([, from]) => from),
152
+ );
140
153
  const scopedCurrent: SchemaSnapshot = opts.scope === 'full'
141
154
  ? { tables: current.tables, enums: current.enums ?? [] }
142
155
  : {
143
- tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table)),
156
+ tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table) || pendingMoveSources.has(t.table)),
144
157
  enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
145
158
  };
146
159
  const renames = compileRenames(models, { schema });
147
- const changes = diffSchema(desired, scopedCurrent, renames, tableRenames);
160
+ const changes = diffSchema(desired, scopedCurrent, renames, tableRenames, tableMoves);
148
161
 
149
162
  const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
150
163
  const emitted = emitSchemaSql(changes, {
@@ -174,11 +187,18 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
174
187
  // data DDL just created. Emitted only when the live contract is supplied; the Models'
175
188
  // `abilities` compile to the same contract shape introspection reads, so the two diff.
176
189
  // Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
177
- const pendingByFrom = new Map(
178
- Object.entries(tableRenames)
190
+ // A moved table carries its policies/grants with it through SET SCHEMA (OID-attached),
191
+ // so the live authz keyed under the OLD schema must be read under the NEW name — else
192
+ // emitReconcileSql finds no live entry and emits a bare CREATE POLICY that collides
193
+ // post-move and rolls back the whole apply (F2). Same rewrite the rename path relies on.
194
+ const pendingByFrom = new Map([
195
+ ...Object.entries(tableRenames)
179
196
  .filter(([, from]) => pendingRenameSources.has(from))
180
197
  .map(([to, from]) => [from, to] as const),
181
- );
198
+ ...Object.entries(tableMoves)
199
+ .filter(([, from]) => pendingMoveSources.has(from))
200
+ .map(([to, from]) => [from, to] as const),
201
+ ]);
182
202
  const liveAuthzRenamed = opts.liveAuthz && pendingByFrom.size > 0
183
203
  ? {
184
204
  ...opts.liveAuthz,
@@ -575,3 +575,20 @@ export function compileTableRenames(models: ModelDescriptor[], opts: { schema?:
575
575
  }
576
576
  return map;
577
577
  }
578
+
579
+ /**
580
+ * Table-level MOVE intent: qualified new name → qualified old name, SAME table name in a
581
+ * DIFFERENT schema (the cross-schema twin of `compileTableRenames`). Produced off the
582
+ * Models' `movedFrom` option, consumed by `diffSchema`'s pre-pass, which turns a
583
+ * create-in-new-schema + undeclared orphan into one `ALTER TABLE … SET SCHEMA …`.
584
+ */
585
+ export function compileTableMoves(models: ModelDescriptor[], opts: { schema?: string } = {}): Record<string, string> {
586
+ const map: Record<string, string> = {};
587
+ for (const model of models) {
588
+ if (!model.movedFrom) continue;
589
+ const newSchema = model.schema ?? opts.schema ?? 'public';
590
+ // Same table name; only the schema changes. from = old schema, to = new schema.
591
+ map[`${newSchema}.${model.table}`] = `${model.movedFrom}.${model.table}`;
592
+ }
593
+ return map;
594
+ }
@@ -72,6 +72,12 @@ export type SchemaChange =
72
72
  | { kind: 'renameTableSatisfied'; table: string; from: string }
73
73
  /** A table marker pointing at a table the database doesn't have — fell back to CREATE, a notice. */
74
74
  | { kind: 'renameTableSourceMissing'; table: string; from: string }
75
+ /** A table-level `movedFrom` satisfied: old-schema table present, new absent → one SET SCHEMA, data preserved. */
76
+ | { kind: 'moveTable'; from: string; to: string }
77
+ /** A move marker whose move is already applied (table already in the new schema) — inert, a notice. */
78
+ | { kind: 'moveTableSatisfied'; table: string; from: string }
79
+ /** A move marker pointing at a table absent in BOTH schemas — fell back to CREATE, a notice. */
80
+ | { kind: 'moveTableSourceMissing'; table: string; from: string }
75
81
  /** A new enum type → `CREATE TYPE … AS ENUM (…)`, emitted before the tables that use it. */
76
82
  | { kind: 'createType'; name: string; values: string[] }
77
83
  /** A value appended to an existing enum → `ALTER TYPE … ADD VALUE` (carries a -- WARNING). */
@@ -105,6 +111,15 @@ export type RenameMap = Record<string, Record<string, string>>;
105
111
  */
106
112
  export type TableRenameMap = Record<string, string>;
107
113
 
114
+ /**
115
+ * Table-level MOVE intent: qualified new name → qualified old name (SAME table name, a
116
+ * DIFFERENT schema). Produced by `compileTableMoves` off the Models' `movedFrom` option;
117
+ * consumed by `diffSchema`'s pre-pass, which rewrites the current snapshot (old-schema
118
+ * table seen under its new qualified name, and every referencing FK re-pointed) so a
119
+ * create-in-new-schema + orphan collapses to one `ALTER TABLE … SET SCHEMA …`.
120
+ */
121
+ export type TableMoveMap = Record<string, string>;
122
+
108
123
  // ---------------------------------------------------------------------------
109
124
  // Normalization — so semantically-equal expressions don't read as drift.
110
125
  // ---------------------------------------------------------------------------
@@ -290,8 +305,9 @@ export function normalizeCheck(expr: string): string {
290
305
  * replaced constraint never clashes with its old self and a column is never dropped out
291
306
  * from under a constraint that still references it.
292
307
  */
293
- export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}, tableRenames: TableRenameMap = {}): SchemaChange[] {
308
+ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}, tableRenames: TableRenameMap = {}, tableMoves: TableMoveMap = {}): SchemaChange[] {
294
309
  const creates: SchemaChange[] = [];
310
+ const moveTables: SchemaChange[] = [];
295
311
  const renameTables: SchemaChange[] = [];
296
312
  const renameColumns: SchemaChange[] = [];
297
313
  const addColumns: SchemaChange[] = [];
@@ -307,6 +323,55 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
307
323
  const currentByName = new Map(current.tables.map((t) => [t.table, t]));
308
324
  const desiredNames = new Set(desired.tables.map((t) => t.table));
309
325
 
326
+ // Table-move pre-pass: honor the Models' cross-schema `movedFrom` before any matching
327
+ // (runs before the rename pre-pass; a table cannot carry both markers — defineModel
328
+ // forbids it). SET SCHEMA carries the table's data, indexes, constraints, policies,
329
+ // grants, triggers and owned sequences with it (OID-attached), so a satisfied move
330
+ // rewrites the current snapshot: the old-schema table is seen under its new qualified
331
+ // name (every downstream column/constraint/index diff lands on it, and the old name
332
+ // never reaches the dropTables sweep), AND every referencing FK is re-pointed old→new.
333
+ // new-name-present → inert notice; source absent in both schemas → CREATE path + notice.
334
+ for (const [to, from] of Object.entries(tableMoves)) {
335
+ if (!desiredNames.has(to)) continue; // the marker's model isn't in this diff
336
+ if (currentByName.has(to)) {
337
+ notices.push({ kind: 'moveTableSatisfied', table: to, from });
338
+ continue;
339
+ }
340
+ const source = currentByName.get(from);
341
+ if (!source) {
342
+ notices.push({ kind: 'moveTableSourceMissing', table: to, from });
343
+ continue;
344
+ }
345
+ moveTables.push({ kind: 'moveTable', from, to });
346
+ currentByName.delete(from);
347
+ // The table's OWNED sequences ride SET SCHEMA too, so a serial default that qualified
348
+ // the old schema (`nextval('stats.foo_id_seq')`) reads `nextval('curated.foo_id_seq')`
349
+ // after the move — rewrite it on the copied source so it does not churn as a spurious
350
+ // SET DEFAULT (F8). Only the moved table's OWN sequence-qualified defaults are touched.
351
+ const fromSchema = from.split('.')[0];
352
+ const toSchema = to.split('.')[0];
353
+ // Literal replace (split/join), not a RegExp — a schema name can be a quoted identifier
354
+ // carrying regex metacharacters, which `new RegExp` would misfire or throw on.
355
+ const requalifyDefault = (d: string | null): string | null =>
356
+ d == null ? d : d.split(`nextval('${fromSchema}.`).join(`nextval('${toSchema}.`);
357
+ currentByName.set(to, {
358
+ ...source,
359
+ table: to,
360
+ columns: source.columns.map((col) => (col.default != null ? { ...col, default: requalifyDefault(col.default) } : col)),
361
+ });
362
+ // F4: re-point every referencing FK (including the moved table's own self-FKs) from
363
+ // the old qualified name to the new, across the WHOLE current snapshot — so a
364
+ // referencing table's FK content-key matches the desired side and Postgres's
365
+ // carried-through FK is not needlessly dropped and re-added.
366
+ for (const [name, t] of currentByName) {
367
+ if (!t.foreignKeys.some((fk) => fk.refTable === from)) continue;
368
+ currentByName.set(name, {
369
+ ...t,
370
+ foreignKeys: t.foreignKeys.map((fk) => (fk.refTable === from ? { ...fk, refTable: to } : fk)),
371
+ });
372
+ }
373
+ }
374
+
310
375
  // Table-rename pre-pass: honor the Models' table-level `renamedFrom` before
311
376
  // any matching. A satisfied rename rewrites the current snapshot — the old
312
377
  // table is seen under its new name — so every downstream diff (columns,
@@ -404,6 +469,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
404
469
  // the other removals; notices are comments — emitted last, after the DDL.
405
470
  return [
406
471
  ...e.creates, ...e.adds, ...sequenceCreates,
472
+ ...moveTables,
407
473
  ...renameTables,
408
474
  ...creates, ...renameColumns, ...addColumns, ...alters, ...trailingDefaults,
409
475
  ...dropConstraints, ...addConstraints, ...createIndexes,
@@ -753,6 +819,16 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
753
819
  case 'renameTable':
754
820
  // RENAME TO takes a bare name — the table stays in its schema.
755
821
  return `ALTER TABLE ${change.from} RENAME TO ${quote(change.to.split('.').pop()!)};`;
822
+ case 'moveTable':
823
+ // SET SCHEMA takes a bare schema name; the table keeps its name and carries its data,
824
+ // indexes, constraints, policies, grants, triggers and owned sequences with it. The
825
+ // WARNING (not NOTICE — this statement is executable; NOTICE is the reserved pure-comment
826
+ // prefix) flags the consumer-breakage surface a move can't see (see F7).
827
+ return `-- WARNING: ${change.from} moves to schema ${change.to.split('.')[0]} — unqualified readers resolve by search_path (verify ordering), and qualified references (views, functions, app queries naming ${change.from}) must be updated + reconciled.\nALTER TABLE ${change.from} SET SCHEMA ${quote(change.to.split('.')[0])};`;
828
+ case 'moveTableSatisfied':
829
+ return `-- NOTICE: the movedFrom marker on table ${change.table} is satisfied — the table already exists in its declared schema, ASSUMED to be the completed move from "${change.from.split('.')[0]}" (a pre-existing unrelated table of the same name would read the same); the marker is now inert and can be removed.`;
830
+ case 'moveTableSourceMissing':
831
+ return `-- NOTICE: table ${change.table} declares movedFrom "${change.from.split('.')[0]}", but the database has it in neither schema — created fresh; remove the marker once every environment is past it.`;
756
832
  case 'renameTableSatisfied':
757
833
  return `-- NOTICE: the renamedFrom marker on table ${change.table} is satisfied — the rename from "${change.from}" is applied, the marker is now inert and can be removed.`;
758
834
  case 'renameTableSourceMissing':
@@ -0,0 +1,67 @@
1
+ /**
2
+ * task-poll — poll an ephemeral Task run (dispatched via the ops Lambda) until it stops.
3
+ *
4
+ * Shared by task:probe and the pg-binary verbs (db:backup / db:export, later restore/swap): they all
5
+ * dispatch a task, get back a run id + ARN, then poll `task:status` until STOPPED. The poll is
6
+ * BOUNDED — a Fargate task can sit in PROVISIONING/PENDING on capacity or ENI trouble, and a naked
7
+ * loop would hang the CLI. A few consecutive DescribeTasks blips are tolerated (a throttle shouldn't
8
+ * abort a live task); past that, or the deadline, the caller reconciles via the run id (the task_log
9
+ * row + ECS both carry it). This owns the loop; the caller owns the success/failure messaging.
10
+ */
11
+
12
+ import { invokeAction } from './aws.js';
13
+ import { info } from './output.js';
14
+
15
+ const POLL_INTERVAL_MS = 5_000;
16
+ /** Backup/export/restore can run minutes on large databases — far longer than the probe's handshake. */
17
+ export const DEFAULT_DEADLINE_MS = 30 * 60_000;
18
+ const MAX_CONSECUTIVE_ERRORS = 3;
19
+
20
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
21
+
22
+ export interface TaskStatus {
23
+ lastStatus?: string;
24
+ stopped?: boolean;
25
+ exitCode?: number | null;
26
+ stoppedReason?: string | null;
27
+ /** The task's self-reported result row (db:backup/export write id/key/bytes/fingerprint). */
28
+ result?: Record<string, unknown> | null;
29
+ error?: string;
30
+ }
31
+
32
+ export type TaskPollResult =
33
+ | { outcome: 'stopped'; status: TaskStatus }
34
+ | { outcome: 'error'; status: TaskStatus }
35
+ | { outcome: 'timeout'; lastStatus: string };
36
+
37
+ /**
38
+ * Poll until the task stops, printing each lifecycle transition. Returns `stopped` (read exitCode),
39
+ * `error` (task:status failed repeatedly — the task may still be running), or `timeout`.
40
+ */
41
+ export async function pollTaskUntilStopped(
42
+ region: string,
43
+ fn: string,
44
+ ids: { runId: string; taskArn: string },
45
+ opts: { deadlineMs?: number } = {},
46
+ ): Promise<TaskPollResult> {
47
+ const deadline = Date.now() + (opts.deadlineMs ?? DEFAULT_DEADLINE_MS);
48
+ let last = '';
49
+ let consecutiveErrors = 0;
50
+ while (Date.now() < deadline) {
51
+ const status = (await invokeAction(region, fn, 'task:status', { runId: ids.runId, taskArn: ids.taskArn })) as TaskStatus;
52
+ if (status?.error) {
53
+ if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'error', status };
54
+ info(` (status check blipped: ${status.error} — retrying)`);
55
+ await sleep(POLL_INTERVAL_MS);
56
+ continue;
57
+ }
58
+ consecutiveErrors = 0;
59
+ if (status.lastStatus && status.lastStatus !== last) {
60
+ info(` ${status.lastStatus}`);
61
+ last = status.lastStatus;
62
+ }
63
+ if (status.stopped) return { outcome: 'stopped', status };
64
+ await sleep(POLL_INTERVAL_MS);
65
+ }
66
+ return { outcome: 'timeout', lastStatus: last || 'unknown' };
67
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @everystack/cli/exec — the db:exec core, for the ops-Lambda lane.
3
+ *
4
+ * db:exec applies credential-free write SQL as ONE transaction, DML-only by a semantic catalog
5
+ * digest (no role, no superuser, no regex), bracketed by a crash-truthful ledger. This barrel lets
6
+ * the ops `db:exec` action (in @everystack/server's dbPlugin) load the same core the CLI uses and
7
+ * run it on the operator connection db:seed already uses — so `db:exec --stage` needs no raw admin
8
+ * URL on the operator's machine.
9
+ */
10
+
11
+ export { executeExec, assertNoTxnControl, execSha } from './cli/exec-execute.js';
12
+ export type { ExecIntent, ExecOutcome, ExecuteExecOptions, ExecResult } from './cli/exec-execute.js';
13
+
14
+ export { runExecInTx, SchemaChangedError, railStatements, EXEC_RAIL_DEFAULTS } from './cli/exec-run.js';
15
+ export type { TxRunner, ExecStmtResult, ExecRails, RunExecInTxOptions } from './cli/exec-run.js';
16
+
17
+ export { catalogDigestQuery } from './cli/exec-digest.js';
18
+
19
+ export { ENSURE_EXEC_LOG_SQL, renderExecIntentInsert, renderExecOutcomeUpdate } from './cli/exec-log.js';
20
+ export type { ExecIntentRow, ExecOutcomeRow } from './cli/exec-log.js';