@everystack/cli 0.4.57 → 0.4.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.57",
3
+ "version": "0.4.58",
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>",
@@ -194,6 +194,12 @@ export async function executeReconcile(
194
194
  // renderEnsureObjectSchemas.
195
195
  const ensureSchemas = renderEnsureObjectSchemas(parsed.objects);
196
196
 
197
+ // Which phase we are in when something throws. A bookkeeping failure inside the atomic path
198
+ // rolls the DDL back too, so the operator sees no plan output and no applied statements —
199
+ // indistinguishable from a batch that never started, and the wrong thing to go debugging.
200
+ // The phase is the difference between "your DDL is bad" and "your DDL was fine".
201
+ let phase: 'ddl' | 'bookkeeping' = 'ddl';
202
+
197
203
  if (atomic) await runner('BEGIN');
198
204
  try {
199
205
  // The session guard: postgres-js `max: 1` is a pool of one, not a session lease — a
@@ -230,11 +236,30 @@ export async function executeReconcile(
230
236
  // the canonical search_path inside a savepoint it always rolls back, so the def hashes are
231
237
  // stable regardless of the create-time wide path above — and that wide path survives the read.
232
238
  // The unwrapped path has no transaction to borrow, so it takes a session of its own.
239
+ phase = 'bookkeeping';
233
240
  const after = await introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
234
241
  const liveById = new Map(after.objects.map((o) => [o.identity, o]));
235
242
  const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
236
243
 
237
244
  const bookkeeping: string[] = [];
245
+ // ORDER MATTERS: migrate, then record, then remove. This ordering is LOAD-BEARING — it is the
246
+ // fix, not the belt. `renderProvenanceMigrate`'s own guard is the belt (see its docstring).
247
+ //
248
+ // A migrate re-keys an EXISTING row (legacy identity → the signature the catalog now reports).
249
+ // The planner emits it only when the target identity is free — but it reads provenance as it
250
+ // stood at PLAN time, and this batch is about to write it. The same object routinely does both:
251
+ // a row old enough to be legacy-keyed is also old enough to predate body_hash, which makes it a
252
+ // `backfill`, which records. Record first and the upsert INSERTs the very identity the migrate
253
+ // then tries to UPDATE onto — duplicate key on the primary key, the whole batch rolled back.
254
+ // Migrating first leaves the row already keyed by its new identity, so the upsert updates it.
255
+ //
256
+ // The guard alone would NOT be enough. Under record-first it makes one case actively worse: a
257
+ // source 'sql'-kind object sharing a legacy function identity (derived-plan.ts:191 skips on
258
+ // `prov.kind === 'sql'` but not `src.kind`) gets its freshly recorded row deleted by the guard,
259
+ // orphaning its provenance for good. Migrating first is what prevents that.
260
+ // NOT YET PINNED BY A TEST — reverting this reorder leaves the whole suite green. See
261
+ // issue:db-reconcile-provenance-migrate-is-the-one-unguarded-write-a (G4).
262
+ for (const m of rendered.migrate) bookkeeping.push(renderProvenanceMigrate(m.from, m.to));
238
263
  for (const identity of rendered.record) {
239
264
  const src = srcById.get(identity);
240
265
  if (!src) {
@@ -253,7 +278,6 @@ export async function executeReconcile(
253
278
  const dropSql = src.kind === 'trigger' && src.table ? triggerDropSql(src.name, src.table) : undefined;
254
279
  bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql, src.bodyHash));
255
280
  }
256
- for (const m of rendered.migrate) bookkeeping.push(renderProvenanceMigrate(m.from, m.to));
257
281
  for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
258
282
  bookkeeping.push(renderSchemaLogInsert({
259
283
  kind: 'compute reconcile',
@@ -287,12 +311,14 @@ export async function executeReconcile(
287
311
  try {
288
312
  await runner(renderSchemaLogInsert({
289
313
  kind: 'compute reconcile', sql: rendered.statements.join(';\n'),
290
- outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
314
+ // The `failed: ` prefix is the repo-wide convention (state-apply records the same shape)
315
+ // and `outcome LIKE 'failed:%'` is what operators query — the phase rides inside it.
316
+ outcome: `failed: [${phase}] ${String(err?.message ?? err).slice(0, 500)}`,
291
317
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
292
318
  durationMs: now() - started,
293
319
  }));
294
320
  } catch { /* the memoir is best-effort on failure */ }
295
- throw explainReconcileError(err);
321
+ throw explainReconcileError(err, phase, atomic);
296
322
  }
297
323
 
298
324
  return { plan, applied: true, statements: rendered.statements, ...(ownerMode ? { ownerMode } : {}) };
@@ -303,8 +329,12 @@ export async function executeReconcile(
303
329
  * and reality disagree — a partial earlier run, a hand-created object — Postgres answers with a
304
330
  * cryptic unique-violation on `pg_type`/`pg_class` instead of anything actionable. Turn that into
305
331
  * a message that tells the operator what to do.
332
+ *
333
+ * `phase` says WHICH half died. A bookkeeping failure rolls the DDL back with it, so the operator's
334
+ * only evidence is an absence — no plan output, nothing applied — which reads as a batch that never
335
+ * started. Say plainly that the DDL was fine, or the next person debugs the wrong half.
306
336
  */
307
- export function explainReconcileError(err: unknown): Error {
337
+ export function explainReconcileError(err: unknown, phase: 'ddl' | 'bookkeeping' = 'ddl', atomic = true): Error {
308
338
  const msg = String((err as any)?.message ?? err);
309
339
  if (/pg_type_typname_nsp_index|pg_class_relname_nsp_index|already exists/i.test(msg)) {
310
340
  return new Error(
@@ -314,6 +344,22 @@ export function explainReconcileError(err: unknown): Error {
314
344
  + `it as drift, re-run with --overwrite-drift.`,
315
345
  );
316
346
  }
347
+ if (phase === 'bookkeeping') {
348
+ return new Error(
349
+ `${msg}\n\nThis failed AFTER the DDL, not in it. The failure came from the post-apply catalog re-read `
350
+ + `or from the provenance/schema_log write that records what was built`
351
+ + (atomic
352
+ ? `. That work shares the DDL's transaction, so the transaction rolled EVERYTHING back and nothing is `
353
+ + `applied — the database is exactly as it was. Resolve the cause and re-run.`
354
+ : `. This batch could not run in a transaction (CONCURRENTLY/VACUUM), so the DDL IS applied and its `
355
+ + `provenance is NOT — and a plain re-run will not record it. Objects that are live with no provenance `
356
+ + `read as first contact, so the next plan reports them under "needs baseline" and applies nothing. `
357
+ + `Recover with --baseline to adopt them as-is (it records trust WITHOUT verifying live matches source), `
358
+ + `or with --rebuild to recreate them from source and record honestly.`)
359
+ + `\n\nThe failed attempt is recorded: SELECT applied_at, outcome FROM everystack.schema_log `
360
+ + `WHERE outcome LIKE 'failed%' ORDER BY applied_at DESC LIMIT 3;`,
361
+ );
362
+ }
317
363
  return err instanceof Error ? err : new Error(msg);
318
364
  }
319
365
 
@@ -497,9 +497,30 @@ ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EX
497
497
  }
498
498
 
499
499
  /** A table rename carried its triggers along — same object, new identity; the provenance
500
- * row migrates instead of the object drop+create-ing. */
500
+ * row migrates instead of the object drop+create-ing. Also re-keys a legacy function row onto
501
+ * the signature identity the catalog reports.
502
+ *
503
+ * `identity` is the PRIMARY KEY, so this UPDATE has no `ON CONFLICT` to fall back on: landing on
504
+ * an occupied identity aborts the batch. The planner will not emit a migration onto a row it can
505
+ * see, and the caller migrates before it records — but neither is a guarantee the statement
506
+ * itself carries, so it carries one. Both rows describe the SAME object; the target is the one
507
+ * every later run reads and the one this batch's own upsert is about to refresh from the live
508
+ * catalog. So the legacy row yields: delete it, keep the target.
509
+ *
510
+ * This guard is the BELT, not the fix. It defends the case ordering cannot reach — a target
511
+ * identity already occupied by a pre-existing row — and it makes the migrate idempotent. The fix
512
+ * for the reported blocker is the caller's migrate-before-record ordering; do not remove that on
513
+ * the strength of this guard, which under record-first would orphan a 'sql'-kind object's
514
+ * provenance. See the ORDER MATTERS comment in db-reconcile.ts.
515
+ *
516
+ * Both branches are mutually exclusive under one snapshot, and an unreferenced data-modifying CTE
517
+ * still executes exactly once — verified against PostgreSQL 16. */
501
518
  export function renderProvenanceMigrate(from: string, to: string): string {
502
- return `UPDATE everystack.derived_provenance SET identity = ${escapeLiteral(to)} WHERE identity = ${escapeLiteral(from)}`;
519
+ const fromLit = escapeLiteral(from);
520
+ const toLit = escapeLiteral(to);
521
+ return `WITH occupied AS (SELECT 1 FROM everystack.derived_provenance WHERE identity = ${toLit}),
522
+ superseded AS (DELETE FROM everystack.derived_provenance WHERE identity = ${fromLit} AND EXISTS (SELECT 1 FROM occupied))
523
+ UPDATE everystack.derived_provenance SET identity = ${toLit} WHERE identity = ${fromLit} AND NOT EXISTS (SELECT 1 FROM occupied)`;
503
524
  }
504
525
 
505
526
  export function renderProvenanceDelete(identity: string): string {