@everystack/cli 0.4.33 → 0.4.34

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.33",
3
+ "version": "0.4.34",
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>",
@@ -206,7 +206,7 @@ export async function executeReconcile(
206
206
  // 'sql'-kind objects are invisible to the catalog: provenance records the source
207
207
  // hash on both sides plus HOW TO REMOVE it (the escape hatch's whole contract).
208
208
  if (src.kind === 'sql') {
209
- bookkeeping.push(renderProvenanceUpsert(identity, src.hash, src.hash, 'sql', src.drop));
209
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, src.hash, 'sql', src.drop, src.bodyHash));
210
210
  continue;
211
211
  }
212
212
  const liveObj = liveById.get(identity);
@@ -214,7 +214,7 @@ export async function executeReconcile(
214
214
  throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
215
215
  }
216
216
  const dropSql = src.kind === 'trigger' && src.table ? triggerDropSql(src.name, src.table) : undefined;
217
- bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql));
217
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql, src.bodyHash));
218
218
  }
219
219
  for (const m of rendered.migrate) bookkeeping.push(renderProvenanceMigrate(m.from, m.to));
220
220
  for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
@@ -292,7 +292,7 @@ export function formatBytes(bytes: number): string {
292
292
  }
293
293
 
294
294
  const VERB_GLYPH: Record<string, string> = {
295
- create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', rebaseline: '≈', prune: '·',
295
+ create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', rebaseline: '≈', prune: '·', backfill: '◌',
296
296
  };
297
297
 
298
298
  export function buildReconcileReport(plan: ReconcilePlan): string[] {
@@ -330,9 +330,12 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
330
330
  }
331
331
 
332
332
  /** Anything that makes `--check` fail: pending work or unresolved findings.
333
- * Baseline and rebaseline are explicit adoption the operator asked for, not findings. */
333
+ * Baseline and rebaseline are explicit adoption the operator asked for, not findings; a
334
+ * backfill is record-only bookkeeping (body_hash arming) — neither is a CI failure. An
335
+ * UNAPPLIED authz-only grant change still fails here via `regrants` below (the real signal);
336
+ * an empty regrant delta means live already matches declared, so green is correct. */
334
337
  export function checkFails(plan: ReconcilePlan): boolean {
335
- return plan.actions.some((a) => a.action !== 'baseline' && a.action !== 'rebaseline')
338
+ return plan.actions.some((a) => a.action !== 'baseline' && a.action !== 'rebaseline' && a.action !== 'backfill')
336
339
  || plan.drift.length > 0
337
340
  || plan.needsBaseline.length > 0
338
341
  || plan.blocked.length > 0
@@ -41,6 +41,10 @@ export const ENSURE_RECONCILER_SQL: string[] = [
41
41
  // databases upgrade in place; their old rows (relations/functions) never need these.
42
42
  `ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS kind text`,
43
43
  `ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS drop_sql text`,
44
+ // body_hash = the source hash MINUS plain grants (authz-only-change discriminator). Idempotent;
45
+ // legacy rows have NULL until the reconciler re-records them, and a NULL body_hash falls back to
46
+ // the rebuild-on-src-change behavior (no regression) — the fix only ARMS once body_hash is recorded.
47
+ `ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS body_hash text`,
44
48
  `CREATE TABLE IF NOT EXISTS everystack.schema_log (
45
49
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
46
50
  applied_at timestamptz NOT NULL DEFAULT now(),
@@ -221,6 +225,12 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
221
225
  record.push(action.identity);
222
226
  break;
223
227
  }
228
+ // Arm the authz-only fast path on an up-to-date object: no DDL — the upsert just adds the
229
+ // body_hash column value the record loop always writes now. Idempotent; converges in one run.
230
+ case 'backfill': {
231
+ record.push(action.identity);
232
+ break;
233
+ }
224
234
  case 'prune': {
225
235
  remove.push(action.identity);
226
236
  break;
@@ -235,10 +245,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
235
245
  return { statements, record, remove, migrate: [...plan.migrations] };
236
246
  }
237
247
 
238
- export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string, kind?: string, dropSql?: string): string {
239
- return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, kind, drop_sql, applied_at)
240
- VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, ${nullable(kind)}, ${nullable(dropSql)}, now())
241
- ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, kind = EXCLUDED.kind, drop_sql = EXCLUDED.drop_sql, applied_at = now()`;
248
+ export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string, kind?: string, dropSql?: string, bodyHash?: string): string {
249
+ return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, kind, drop_sql, body_hash, applied_at)
250
+ VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, ${nullable(kind)}, ${nullable(dropSql)}, ${nullable(bodyHash)}, now())
251
+ ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, kind = EXCLUDED.kind, drop_sql = EXCLUDED.drop_sql, body_hash = EXCLUDED.body_hash, applied_at = now()`;
242
252
  }
243
253
 
244
254
  /** A table rename carried its triggers along — same object, new identity; the provenance
@@ -22,14 +22,22 @@ import type {
22
22
  FunctionDescriptor, SqlDescriptor, TriggerSpec, Ability, SetofReturn, DependsOnRef,
23
23
  } from '@everystack/model';
24
24
  import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
25
+ import { isPlainGrantAttachment } from './derived-grants.js';
25
26
  import { findInvokerReachabilityGaps } from './derived-lint.js';
26
27
 
27
28
  /** The provenance marker for descriptor-compiled objects (SourceObject.file). */
28
29
  const DECLARED = DECLARED_SOURCE_FILE;
29
30
 
30
- /** `public.x` renders bare (authored style — hash parity with hand-written sources); other schemas qualify. */
31
+ /**
32
+ * Always schema-qualify — the CREATE/GRANT/COMMENT target must be `schema.name` so the object lands
33
+ * in its DECLARED schema regardless of the apply-time search_path order. A bare `public` name landed
34
+ * in search_path[0] instead (derivedSearchPath puts public LAST), so a new public derived object
35
+ * created while a non-public derived schema existed mislanded into the wrong schema and the record
36
+ * loop couldn't find it. (The old bare-public rendering chased hash parity with hand-written db/sql
37
+ * sources; db/sql is retired — B7 — so that rationale is dead. Qualifying is the correctness rule.)
38
+ */
31
39
  function renderName(schema: string, name: string): string {
32
- return schema === 'public' ? name : `${schema}.${name}`;
40
+ return `${schema}.${name}`;
33
41
  }
34
42
 
35
43
  /** The declared name of a dependable ref, for rendering (`SETOF posts`) and identity. */
@@ -243,6 +251,9 @@ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachme
243
251
  identity: extra.identity ?? `${schema}.${name}`,
244
252
  sql, attachments,
245
253
  hash: hashSourceContent(sql, attachments),
254
+ // bodyHash excludes PLAIN grants (column-scoped grants stay in — attacl isn't drift-checked, so
255
+ // a column-grant change must still rebuild). Equal across an authz-only plain-grant change.
256
+ bodyHash: hashSourceContent(sql, attachments.filter((a) => !isPlainGrantAttachment(a))),
246
257
  file: DECLARED, seq,
247
258
  ...extra,
248
259
  });
@@ -41,6 +41,21 @@ export interface ParsedGrants {
41
41
  const GRANT_RE = /^GRANT\s+([A-Z]+)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
42
42
  const REVOKE_PUBLIC_RE = /^REVOKE\s+ALL\s+ON\s+(.+?)\s+FROM\s+PUBLIC$/i;
43
43
 
44
+ /**
45
+ * A PLAIN (non-column-scoped) grant/revoke attachment — a table/function-level `GRANT … TO …` with
46
+ * no column list, or a `REVOKE ALL … FROM PUBLIC`. These are the grants `diffObjectGrants` can fully
47
+ * express, so `bodyHash` excludes them: an authz-only change to them applies as a bare regrant, never
48
+ * a rebuild. Column-scoped grants (`GRANT SELECT ("a","b") …`) are NOT plain — attacl is not
49
+ * introspected, so a change to them must still rebuild; they stay in `bodyHash`. Non-grant
50
+ * attachments (index/comment) are structural and always return false.
51
+ */
52
+ export function isPlainGrantAttachment(a: Attachment): boolean {
53
+ if (a.kind !== 'grant') return false;
54
+ if (REVOKE_PUBLIC_RE.test(a.sql)) return true;
55
+ const g = a.sql.match(GRANT_RE);
56
+ return g !== null && !g[2]; // g[2] is the (columns) group — present => column-scoped => not plain
57
+ }
58
+
44
59
  /** The declared grant contract, read back from an object's own grant attachments. */
45
60
  export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
46
61
  const grants: Record<string, Set<string>> = {};
@@ -81,6 +81,9 @@ export interface ProvenanceRow {
81
81
  kind?: string;
82
82
  /** How to remove what was recorded — the C1 closure: removal never orphans. */
83
83
  dropSql?: string;
84
+ /** The source hash MINUS plain grants at record time. Present only once the reconciler has
85
+ * re-recorded the row post-fix; absent on legacy rows → the planner falls back to rebuild. */
86
+ bodyHash?: string;
84
87
  }
85
88
 
86
89
  export interface DerivedCatalog {
@@ -514,6 +517,7 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
514
517
  defHash: String(r.def_hash ?? ''),
515
518
  ...(r.extra?.kind != null ? { kind: String(r.extra.kind) } : {}),
516
519
  ...(r.extra?.drop_sql != null ? { dropSql: String(r.extra.drop_sql) } : {}),
520
+ ...(r.extra?.body_hash != null ? { bodyHash: String(r.extra.body_hash) } : {}),
517
521
  }))
518
522
  .sort((a, b) => a.identity.localeCompare(b.identity));
519
523
 
@@ -29,7 +29,7 @@ import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
29
29
  import { triggerDropSql } from './derived-apply.js';
30
30
  import { parseGrantAttachments, diffObjectGrants } from './derived-grants.js';
31
31
 
32
- export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune';
32
+ export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
33
33
 
34
34
  export interface ReconcileAction {
35
35
  action: ReconcileVerb;
@@ -192,6 +192,9 @@ export function planReconcile(
192
192
  const baseline: string[] = [];
193
193
  const rebaseline: string[] = [];
194
194
  const prune: string[] = [];
195
+ /** Up-to-date objects (src+live unchanged) whose provenance predates body_hash — record it once
196
+ * so a FUTURE authz-only change is a regrant, not a rebuild. Record-only, no DDL; arms the fix. */
197
+ const backfill: string[] = [];
195
198
 
196
199
  // -------------------------------------------------------------------------
197
200
  // The decision table.
@@ -289,14 +292,34 @@ export function planReconcile(
289
292
  continue;
290
293
  }
291
294
  if (srcChanged) {
295
+ // Authz-only change: the BODY (and live) is untouched, only plain grants moved — bodyHash
296
+ // matches provenance. Route it like a rebaseline: no rebuild, re-record the (new) source +
297
+ // body hash, and the grant-drift pass below (which covers skipped ∪ rebaseline) applies the
298
+ // bare GRANT/REVOKE delta. Gate is strict — any leg missing falls through to today's rebuild:
299
+ // - bodyHash recorded AND equal → the change is grants-only, not body (armed row only)
300
+ // - live grants introspected (≠ undef) → diffObjectGrants can actually run (absent ≠ empty)
301
+ // - no column-scoped grants → attacl isn't drift-applied, so those MUST rebuild
302
+ const parsed = parseGrantAttachments(src.attachments);
303
+ const authzOnly =
304
+ prov.bodyHash != null &&
305
+ src.bodyHash === prov.bodyHash &&
306
+ liveObj.grants !== undefined &&
307
+ !parsed.hasColumnGrants;
292
308
  // The live object is verifiably untouched here (liveChanged was handled above), so
293
309
  // under rebaseline a source re-render is bookkeeping: re-record the source hash,
294
310
  // rebuild nothing, cascade nothing.
295
- if (options.rebaseline) rebaseline.push(src.identity);
311
+ if (options.rebaseline || authzOnly) rebaseline.push(src.identity);
296
312
  else if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
297
313
  else fnReplace.set(src.identity, 'source changed');
298
314
  continue;
299
315
  }
316
+ // Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
317
+ // once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
318
+ // Converges: after this run the row has body_hash, so it skips cleanly next time.
319
+ if (prov.bodyHash == null) {
320
+ backfill.push(src.identity);
321
+ continue;
322
+ }
300
323
  skipped.push(src.identity);
301
324
  }
302
325
 
@@ -336,7 +359,7 @@ export function planReconcile(
336
359
  // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
337
360
  // and running the checks NOW means a migration converges (grants ride the same apply)
338
361
  // instead of surfacing on the next plan.
339
- const lensSet = new Set([...skipped, ...rebaseline]);
362
+ const lensSet = new Set([...skipped, ...rebaseline, ...backfill]);
340
363
  for (const src of source.objects) {
341
364
  if (!lensSet.has(src.identity)) continue;
342
365
  if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
@@ -485,6 +508,7 @@ export function planReconcile(
485
508
  .map(([id, reason]) => actionFor(id, 'refresh', reason)),
486
509
  ...baseline.sort().map((id) => actionFor(id, 'baseline', 'recording provenance for an existing match (trusted once, explicitly)')),
487
510
  ...rebaseline.sort().map((id) => actionFor(id, 'rebaseline', 'source re-rendered — live verified untouched (defHash match); provenance re-recorded, no rebuild')),
511
+ ...backfill.sort().map((id) => actionFor(id, 'backfill', 'recording body_hash on an up-to-date object — arms the authz-only fast path, no rebuild')),
488
512
  ...prune.sort().map((id) => ({
489
513
  action: 'prune' as const, identity: id, kind: 'view' as DerivedKind,
490
514
  reason: 'stale provenance — object gone from both source and database',
@@ -48,6 +48,14 @@ export interface SourceObject {
48
48
  attachments: Attachment[];
49
49
  /** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
50
50
  hash: string;
51
+ /**
52
+ * sha256 of the normalized CREATE + attachments EXCEPT plain (non-column-scoped) grants — the
53
+ * "would a rebuild be required" hash. Equal across an authz-only change (plain grants moved, body
54
+ * unchanged), so the reconciler can route that to a bare GRANT delta instead of a matview rebuild.
55
+ * Column-scoped grants stay IN (attacl not introspected → they must rebuild). `hash` stays the
56
+ * full/stable formula for provenance compat; this is the additional discriminator.
57
+ */
58
+ bodyHash: string;
51
59
  /** Source file this object came from (descriptors: the declared-models marker). */
52
60
  file: string;
53
61
  /** Position in the concatenated source — a valid dependency order by convention. */