@everystack/cli 0.4.45 → 0.4.46

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.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +75 -26
  5. package/src/cli/authz-canonical.ts +37 -5
  6. package/src/cli/authz-compile.ts +87 -37
  7. package/src/cli/authz-contract.ts +92 -19
  8. package/src/cli/authz-derive.ts +158 -33
  9. package/src/cli/authz-reconcile.ts +48 -6
  10. package/src/cli/aws.ts +32 -0
  11. package/src/cli/commands/db-apply.ts +60 -14
  12. package/src/cli/commands/db-authz.ts +9 -14
  13. package/src/cli/commands/db-fingerprint.ts +54 -18
  14. package/src/cli/commands/db-generate.ts +11 -17
  15. package/src/cli/commands/db-plan.ts +56 -8
  16. package/src/cli/commands/db-pull.ts +16 -18
  17. package/src/cli/commands/db-reconcile.ts +18 -20
  18. package/src/cli/commands/db-swap.ts +5 -4
  19. package/src/cli/commands/db-sync.ts +8 -5
  20. package/src/cli/db-build.ts +2 -2
  21. package/src/cli/db-source.ts +56 -0
  22. package/src/cli/derived-introspect.ts +27 -26
  23. package/src/cli/derived-lint.ts +7 -8
  24. package/src/cli/edge-plan.ts +112 -16
  25. package/src/cli/git-descent.ts +16 -9
  26. package/src/cli/index.ts +1 -1
  27. package/src/cli/model-render.ts +56 -50
  28. package/src/cli/schema-compile.ts +6 -1
  29. package/src/cli/schema-diff.ts +1 -1
  30. package/src/cli/schema-fingerprint.ts +67 -7
  31. package/src/cli/schema-introspect.ts +44 -17
  32. package/src/cli/schema-source.ts +9 -0
  33. package/src/cli/session.ts +184 -0
  34. package/src/cli/stage-read-consistency.ts +128 -0
  35. package/src/cli/state-apply.ts +4 -2
  36. package/src/cli/swap-execute.ts +4 -3
  37. package/src/cli/search-path.ts +0 -51
@@ -97,13 +97,24 @@ export const ABILITY_PRESETS: Record<string, string[]> = {
97
97
  };
98
98
 
99
99
  /**
100
- * A rendered ability that is a PUBLIC read — anon-visible, the only shape the soft-delete
101
- * guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
102
- * `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
103
- * so one ability's `role:` can never mask another's public read.
100
+ * A rendered ability that is a PUBLIC read — anon-visible, the shape the soft-delete guard
101
+ * applies to. Tested per-ability (never against the whole joined stanza) so one ability's
102
+ * `role:` can never mask another's public read.
103
+ *
104
+ * The TEXT form of `isPublicReadAbility` from `@everystack/model`, which is the structured
105
+ * one `defineModel`'s soft-delete refusal uses. Two representations because this renderer only
106
+ * ever holds rendered source (derive emits text, not descriptors), one meaning — pinned by a
107
+ * differential test over the ability matrix, the same way `tableReaches` is pinned against the
108
+ * grant compiler. They used to disagree: this counted `role: 'anon'`, the refusal did not, and
109
+ * a table whose authenticated read diverges renders exactly that shape. The refusal now fires
110
+ * on the anonymous audience however it is spelled, so both surfaces answer alike.
104
111
  */
105
- function isPublicReadAbility(expr: string): boolean {
106
- return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
112
+ export function isPublicReadAbility(expr: string): boolean {
113
+ const e = expr.trim();
114
+ if (!/^can\('read'/.test(e)) return false;
115
+ if (/\b(owner|via)\s*:/.test(e)) return false;
116
+ const role = /\brole\s*:\s*'([^']+)'/.exec(e);
117
+ return !role || role[1] === 'anon';
107
118
  }
108
119
 
109
120
  /**
@@ -156,56 +167,47 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
156
167
  * nobody wrote. The comment says how to get the other one, so the decision is visible in the
157
168
  * file rather than buried in a compiler convention.
158
169
  */
170
+ /**
171
+ * `writtenBy`, rendered ONLY when the live table is not FORCEd.
172
+ *
173
+ * `writtenBy` is not documentation — it is the sole input to RLS FORCE (`forced: writtenBy ===
174
+ * 'app'`, the compiler's one use of it), and it defaults to `'app'`. So a pulled model that
175
+ * omits it silently declares FORCE on every table, and the next plan carries an
176
+ * `ALTER TABLE … FORCE ROW LEVEL SECURITY` against a table someone deliberately left unforced
177
+ * — a real change to who bypasses row security, arriving in a plan as though it were adoption.
178
+ * The reference schema happens to be FORCEd throughout, which is exactly why this went unseen.
179
+ *
180
+ * `'worker'` is not a guess about the writer; it is the value whose COMPILED CONSEQUENCE
181
+ * matches the live bit. The comment says so, because a reader who takes it as a claim about
182
+ * the application would be misled — and this is a line they must consciously keep or change.
183
+ */
184
+ function writtenByStanza(table: TableSchema, liveAuthz?: Map<string, TableContract>): string {
185
+ const live = liveAuthz?.get(table.table);
186
+ if (!live || live.rls.forced) return '';
187
+ return ` writtenBy: 'worker', // live reality: RLS is not FORCEd here, and only writtenBy controls that. `
188
+ + `'app' (the default) would FORCE it — a change to who bypasses row security, not an adoption.\n`;
189
+ }
190
+
159
191
  function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
160
192
  const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
161
193
  if (!hasColumn || !publicRead) return '';
162
194
  return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
163
195
  }
164
196
 
165
- /** `format_type` the `field.*()` factory that produces it (the non-parameterized types). */
166
- const FIELD_FACTORY: Record<string, string> = {
167
- uuid: 'uuid',
168
- text: 'text',
169
- integer: 'integer',
170
- bigint: 'bigint',
171
- real: 'real',
172
- 'double precision': 'doublePrecision',
173
- boolean: 'boolean',
174
- json: 'json',
175
- jsonb: 'jsonb',
176
- date: 'date',
177
- 'timestamp with time zone': 'timestamptz',
178
- 'timestamp without time zone': 'timestamp',
179
- timestamp: 'timestamp',
180
- };
197
+ // The inverse type map lives with the vocabulary it inverts (`@everystack/model`),
198
+ // where `field.pgType` refuses the spellings a first-class field owns — one
199
+ // definition, two callers, so render and refusal can never disagree.
200
+ import { fieldFactoryCall } from '@everystack/model';
201
+ export { fieldFactoryCall };
181
202
 
182
203
  /**
183
- * The full `field.*()` call for a `format_type` string, or null when no field maps it.
184
- * Handles the parameterized types `format_type` spells out `numeric(p,s)` and
185
- * `character varying(n)` — so a pulled column round-trips back to the same DDL. A
186
- * scale-0 numeric renders as the cleaner `field.numeric(p)` (identical to `(p, 0)`).
204
+ * The verbatim fallback for a type no first-class field owns: carry the exact
205
+ * `format_type` spelling with `field.pgType(...)`. An array carries its scalar
206
+ * plus `.array()` — the factory refuses a bracketed spelling by design.
187
207
  */
188
- export function fieldFactoryCall(type: string): string | null {
189
- // An array is its base type + `.array()` — exactly how the compiler emits it
190
- // (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
191
- if (type.endsWith('[]')) {
192
- const base = fieldFactoryCall(type.slice(0, -2));
193
- return base ? `${base}.array()` : null;
194
- }
195
-
196
- const direct = FIELD_FACTORY[type];
197
- if (direct) return `field.${direct}()`;
198
-
199
- const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
200
- if (num) {
201
- if (num[1] == null) return 'field.numeric()';
202
- return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
203
- }
204
-
205
- const vc = type.match(/^character varying(?:\((\d+)\))?$/);
206
- if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
207
-
208
- return null;
208
+ function verbatimFieldCall(type: string): string {
209
+ if (type.endsWith('[]')) return `${verbatimFieldCall(type.slice(0, -2))}.array()`;
210
+ return `field.pgType(${tsLiteral(type)})`;
209
211
  }
210
212
 
211
213
  /** `public.image_variants` / `image_variants` → `image_variants` (the bare table name). */
@@ -391,8 +393,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
391
393
  ?? (enumValues
392
394
  ? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
393
395
  : fieldFactoryCall(col.type));
394
- let expr = call ?? 'field.text()';
395
- let comment = call ? '' : ` // FIXME: column type '${col.type}' has no field mapping`;
396
+ // No first-class field owns the type → carry it VERBATIM. The column's exact
397
+ // type is the identity, so the round trip is clean: no coercion, no FIXME, no
398
+ // plan statement. The note stays — a verbatim type is a fact worth seeing.
399
+ let expr = call ?? verbatimFieldCall(col.type);
400
+ let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
396
401
 
397
402
  const isPk = table.primaryKey.includes(col.name);
398
403
  if (isPk) expr += '.primaryKey()';
@@ -503,9 +508,10 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
503
508
  // reviewer must resolve about a model (and where the field-report consumer's codemod
504
509
  // put it, proving the position is mechanical-edit-friendly).
505
510
  const stanza = abilitiesStanza(abilities, table, liveAuthz);
511
+ const writtenBy = writtenByStanza(table, liveAuthz);
506
512
  const softDelete = softDeleteStanza(table, stanza.publicRead);
507
513
 
508
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
514
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
509
515
  }
510
516
 
511
517
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -124,9 +124,14 @@ function scalarPgType(spec: FieldSpec): string {
124
124
  if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
125
125
  return spec.enumName;
126
126
  }
127
+ if (spec.type === 'pgType') {
128
+ // A verbatim type: the declared string IS the canonical `format_type` spelling
129
+ // (the factory refused anything else), so it round-trips byte-for-byte.
130
+ if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
131
+ return spec.pgTypeName;
132
+ }
127
133
  const t = PG_TYPE[spec.type];
128
134
  if (!t) {
129
- // geometry needs PostGIS — a deliberate follow-up, not a silent wrong column.
130
135
  throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
131
136
  }
132
137
  return t;
@@ -520,7 +520,7 @@ function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
520
520
  * the consumer's 31 phantom drop/creates). Plain btree indexes key exactly as before
521
521
  * plus a constant tail — both sides compute the same key, so nothing churns.
522
522
  */
523
- function indexKey(ix: IndexSchema): string {
523
+ export function indexKey(ix: IndexSchema): string {
524
524
  const entries = ix.columns.map((c) => normalizeCheck(c)).join(',');
525
525
  return `${ix.unique ? 'u' : ''}|${entries}|${normalizeCheck(ix.where ?? '')}|${ix.using ?? 'btree'}|${(ix.include ?? []).join(',')}`;
526
526
  }
@@ -52,7 +52,8 @@ import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
52
52
  import type { AuthzContract, TableContract } from './authz-contract.js';
53
53
  import { compileTableSchema, compileEnums, compileSequences } from './schema-compile.js';
54
54
  import { compileTableContract } from './authz-compile.js';
55
- import { normalizeDefault, normalizeCheck } from './schema-diff.js';
55
+ import { governedRoleSet } from './authz-reconcile.js';
56
+ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
56
57
 
57
58
  // v2: expression fields (defaults, checks, partial-index WHERE) normalize through
58
59
  // the diff's pg-deparse normalizers before hashing, so the model form and the live
@@ -73,7 +74,43 @@ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
73
74
  // an ungoverned migrator/ETL role alone and a hash that counts it can never converge.
74
75
  // Together these restore the identity the format exists for: fingerprints match exactly when
75
76
  // db:generate is a no-op.
76
- export const FINGERPRINT_VERSION = 4;
77
+ // v5 a redundant DEFAULT PRECISION is no longer part of a column's identity.
78
+ // `timestamp(6) without time zone` and `timestamp without time zone` are the same type (6 is
79
+ // the family's default), so they now canonicalize to one spelling in
80
+ // `canonicalColumnType`. Two columns that always stored identical values used to hash
81
+ // differently, which meant a schema carrying the explicit spelling could never reach MATCH.
82
+ // Only precision 6 and only the timestamp family: `timestamp(3)` is a real difference.
83
+ // Consequence, and the reason this is a VERSION and not a silent fix: a plan or baseline
84
+ // minted under v4 against such a schema refuses as a FORMAT CHANGE and must be re-minted.
85
+ // No DDL, no data movement — the fingerprint is a content address.
86
+ // v6 — an INDEX'S IDENTITY is the differ's content key, verbatim. The v5 form hashed
87
+ // {columns, unique, where} only, so a GIN and a btree index on the same column hashed
88
+ // EQUAL while `db:generate` emitted a DROP + CREATE — MATCH could lie exactly where it
89
+ // matters for tsvector/geometry (GIN/GiST). The v5 form also hashed indexes as a
90
+ // multiset while the diff dedupes by content, so a physically duplicate index (two
91
+ // identical indexes under different names — a common migration-era artifact) made
92
+ // MATCH unreachable even though the diff was, correctly, a no-op. Both fixed the same
93
+ // way: the canonical index entry is now `indexKey` from schema-diff — access method and
94
+ // INCLUDE in, key entries normalized, names out, duplicates collapsed — ONE definition
95
+ // for "are these the same index", shared by the differ and the hash. Same consequence
96
+ // as every bump: plans/baselines minted under v5 refuse as FORMAT CHANGE and re-mint.
97
+ // v7 — a DEAD policy is not part of the state. A policy governing a privilege none of its
98
+ // roles holds authorizes nothing: Postgres refuses at the GRANT before ever consulting it.
99
+ // The reconciler now leaves such a policy alone (dropping it changes no access, so it was
100
+ // pure churn an adopter had to read and approve), and the canonical form stops counting it —
101
+ // the same choice from the same function, `isPolicyDead`, which also backs the adoption
102
+ // classifier's `dead` class and db:pull's notes. Without both halves the state could not
103
+ // converge on any brownfield database carrying policies whose grants were revoked long ago.
104
+ // Deadness is recomputed against live grants every time and never stored, so a policy
105
+ // RE-ENTERS the state the moment a grant makes it effective.
106
+ // v8 — a SUBSUMED policy is not part of the state either. Permissive policies OR together and
107
+ // a PUBLIC policy applies to every role, so a role-scoped policy whose rule is identical to a
108
+ // PUBLIC one admits no session the other does not: it changes no access, so it is churn, not
109
+ // state. Same treatment and same lockstep as v7's dead policies — `isPolicySubsumed` excludes
110
+ // it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
111
+ // policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
112
+ // exactly including the effective WITH CHECK.
113
+ export const FINGERPRINT_VERSION = 8;
77
114
 
78
115
  // ---------------------------------------------------------------------------
79
116
  // Canonical form.
@@ -118,11 +155,13 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
118
155
  })),
119
156
  (f) => stableStringify(f),
120
157
  ),
121
- indexes: byKey(
122
- // Partial-index WHERE is a predicate too normalize it the same way as a check.
123
- table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: normalizeCheck(i.where) } : {}) })),
124
- (i) => stableStringify(i),
125
- ),
158
+ // v6: an index's canonical form IS the differ's content key — one definition,
159
+ // both surfaces. That brings the access method (`using`) and INCLUDE set into
160
+ // the identity (a GIN and a btree on the same column are different states),
161
+ // normalizes key entries the way the diff compares them, and DEDUPES: two
162
+ // physically duplicate indexes are one state, exactly as the diff (which can
163
+ // neither create nor drop the second copy) already treats them.
164
+ indexes: [...new Set(table.indexes.map(indexKey))].sort(),
126
165
  };
127
166
  }
128
167
 
@@ -251,6 +290,27 @@ export function fingerprintModels(
251
290
  return fingerprintState(snapshot, authzTables, opts.schemas ? { schemas: opts.schemas } : {});
252
291
  }
253
292
 
293
+ /**
294
+ * The governed-role set a model checkout implies — THE set every declared-vs-live
295
+ * comparison must filter the live side through. One derivation, all callers:
296
+ * db:fingerprint's MATCH, the mint's `to` endpoint, the apply's verify-after and
297
+ * already-applied checks, and git-descent's declares-test. (v4 introduced the
298
+ * filter in the canonical layer; a live grant nothing will ever reconcile is not
299
+ * part of the state the models describe. Leaving a caller unfiltered re-creates
300
+ * the exact defect v4 fixed: zero statements, MATCH unreachable.)
301
+ * `extra` is the modules' widened `governedRoles` declaration.
302
+ */
303
+ export function governedRolesForModels(
304
+ models: ModelDescriptor[],
305
+ extra?: readonly string[],
306
+ ): ReadonlySet<string> {
307
+ const declared: AuthzContract = {
308
+ tables: models.map((m) => compileTableContract(m, {})),
309
+ functions: [],
310
+ };
311
+ return governedRoleSet(declared, extra ?? []);
312
+ }
313
+
254
314
  /** Convenience: the live side, from the two existing introspections. */
255
315
  export function fingerprintLive(
256
316
  snapshot: SchemaSnapshot,
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
17
- import { withCanonicalSearchPath } from './search-path.js';
17
+ import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // The data-layer snapshot — the structured shape both producers target.
@@ -281,6 +281,33 @@ export interface ColumnRow {
281
281
  position: unknown;
282
282
  }
283
283
 
284
+ /**
285
+ * Drop a precision qualifier that only restates the default.
286
+ *
287
+ * `timestamp(6) without time zone` and `timestamp without time zone` are THE SAME TYPE:
288
+ * 6 is Postgres's default precision for the timestamp family, so a column declared either
289
+ * way stores identical values with identical semantics. `format_type` still spells the
290
+ * explicit one with its typmod, because the catalog records that somebody typed it.
291
+ *
292
+ * Everything downstream compares these strings — the renderer to pick a `field.*()`, the
293
+ * differ to decide an ALTER, the fingerprint to content-address the schema. With the two
294
+ * spellings distinct, a brownfield column declared `timestamp(6)` had NO field mapping, so
295
+ * db:pull rendered `field.text()` with a FIXME and the next plan proposed rewriting the
296
+ * column to text — destroying a column to reconcile a difference that does not exist. On a
297
+ * real adopter's schema that was 18 of 48 destructive statements.
298
+ *
299
+ * Normalized HERE, at the single funnel every consumer reads, rather than at each comparison:
300
+ * three surfaces answering one question separately is how they drift.
301
+ *
302
+ * ONLY precision 6, and only for the timestamp family. `timestamp(3)` genuinely truncates to
303
+ * milliseconds — a real difference that must keep saying so.
304
+ */
305
+ // The one funnel — a type's canonical spelling is defined ONCE, in the model
306
+ // package, so the field factory (`field.pgType` refusals), this introspection,
307
+ // the renderer, the differ, and the fingerprint can never disagree about it.
308
+ import { canonicalColumnType } from '@everystack/model';
309
+ export { canonicalColumnType };
310
+
284
311
  export function columnRowToDescriptor(row: ColumnRow): { table: string; column: ColumnSchema; position: number } {
285
312
  const def = row.default == null ? null : String(row.default);
286
313
  return {
@@ -288,7 +315,7 @@ export function columnRowToDescriptor(row: ColumnRow): { table: string; column:
288
315
  position: Number(row.position),
289
316
  column: {
290
317
  name: row.column,
291
- type: String(row.type),
318
+ type: canonicalColumnType(String(row.type)),
292
319
  notNull: coerceBool(row.not_null),
293
320
  default: def && def.length > 0 ? def : null,
294
321
  },
@@ -604,20 +631,20 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
604
631
  * (the ops Lambda `db:query` in production, a fake in tests) and folds the rows with
605
632
  * `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
606
633
  */
607
- export async function introspectSchema(run: QueryRunner): Promise<SchemaSnapshot> {
608
- // Pin the canonical search_path: column defaults, CHECK constraints, and index
609
- // expressions all deparse relative to it, so an ambient non-default path would
610
- // read as false drift. See search-path.ts.
611
- return withCanonicalSearchPath(run, async () => {
612
- const [columns, constraints, enums, indexes, sequences] = await Promise.all([
613
- run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL), run(SEQUENCES_SQL),
614
- ]);
615
- return assembleSchema({
616
- columns: columns as ColumnRow[],
617
- constraints: constraints as ConstraintRow[],
618
- enums: enums as EnumRow[],
619
- indexes: indexes as IndexRow[],
620
- sequences: sequences as SequenceRow[],
621
- });
634
+ export async function introspectSchema(session: SessionRunner): Promise<SchemaSnapshot> {
635
+ // ONE session, so all five queries see one database at one moment, under one pinned
636
+ // search_path. Column defaults, CHECK constraints and index expressions all deparse
637
+ // relative to that path spread across separate connections these five rows can render
638
+ // the same schema two ways, and a fingerprint minted from the mix describes nothing.
639
+ const [columns, constraints, enums, indexes, sequences] = await session(
640
+ [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL],
641
+ INTROSPECTION_SESSION,
642
+ );
643
+ return assembleSchema({
644
+ columns: columns as ColumnRow[],
645
+ constraints: constraints as ConstraintRow[],
646
+ enums: enums as EnumRow[],
647
+ indexes: indexes as IndexRow[],
648
+ sequences: sequences as SequenceRow[],
622
649
  });
623
650
  }
@@ -114,6 +114,15 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
114
114
  if (!spec.enumName) throw new Error('enum field is missing enumName');
115
115
  return { call: `${enumConstName(spec.enumName)}(${n})`, builder: enumConstName(spec.enumName) };
116
116
  }
117
+ case 'pgType': {
118
+ // A verbatim type: the generated schema carries the exact string via drizzle's
119
+ // customType — same as the runtime builder in @everystack/model.
120
+ if (!spec.pgTypeName) throw new Error('pgType field is missing pgTypeName');
121
+ return {
122
+ call: `customType<{ data: unknown }>({ dataType: () => ${strLiteral(spec.pgTypeName)} })(${n})`,
123
+ builder: 'customType',
124
+ };
125
+ }
117
126
  default: {
118
127
  const exhaustive: never = spec.type;
119
128
  throw new Error(`Unsupported field type: ${String(exhaustive)}`);
@@ -0,0 +1,184 @@
1
+ /**
2
+ * SessionRunner — the type that can express "these statements describe ONE moment".
3
+ *
4
+ * `QueryRunner` (authz-contract.ts) runs one SQL string and returns rows. It cannot say
5
+ * that two statements shared a connection, because on the stage lane they do not: each
6
+ * one is its own ops-Lambda invoke, answered by whichever container is warm. An
7
+ * introspection built from five of those is a collage, and a fingerprint minted from a
8
+ * collage describes no database that ever existed.
9
+ *
10
+ * So this is a SECOND type, not a widening of the first. A function that takes a
11
+ * `SessionRunner` cannot be handed a session-less venue — the compiler refuses it. That
12
+ * is the whole point: the guarantee is in the type, not in a convention or a capability
13
+ * flag that degrades quietly when the backend cannot honor it.
14
+ *
15
+ * Both venues implement it. The stage lane sends the statements to the ops Lambda's
16
+ * `db:session` action (one invoke, one connection, one transaction); the direct lane runs
17
+ * them in one postgres.js transaction on its single connection. Same contract, same
18
+ * ordering, same `SET LOCAL` semantics.
19
+ */
20
+
21
+ /** One statement. A bare string is shorthand for `{ sql, allowFailure: false }`. */
22
+ export interface SessionStatement {
23
+ sql: string;
24
+ /**
25
+ * Survive a failure of THIS statement, leaving the other result slots intact — the
26
+ * venue wraps it in a savepoint. For "this catalog table may not exist yet"
27
+ * (`derived_provenance`, `backfill_log`, `schema_log`), which is otherwise a
28
+ * try/catch that can only be written by giving up the session.
29
+ */
30
+ allowFailure?: boolean;
31
+ }
32
+
33
+ export interface SessionOptions {
34
+ /** Run the transaction READ ONLY. The caller's policy — no venue assumes it. */
35
+ readOnly?: boolean;
36
+ /** Isolation level; 'repeatable read' gives every statement one snapshot. */
37
+ isolation?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable';
38
+ /**
39
+ * Pin `search_path` for the transaction. Emitted as `SET LOCAL` inside the session and
40
+ * CONSUMING NO RESULT SLOT — results stay indexed by statement. Never prepend the pin
41
+ * as a statement of your own; that shifts every index after it.
42
+ */
43
+ searchPath?: string;
44
+ }
45
+
46
+ /** A statement that failed under `allowFailure`. Occupies its slot; carries the cause. */
47
+ export interface SessionStatementError {
48
+ everystackSessionError: true;
49
+ message: string;
50
+ }
51
+
52
+ /** One slot: the statement's rows, or the marker for an allowed failure. */
53
+ export type SessionResult = any[] | SessionStatementError;
54
+
55
+ /**
56
+ * Run N statements on ONE connection in ONE transaction; N result sets, in input order.
57
+ *
58
+ * Nothing about a `SessionRunner` is optional or best-effort. A venue that cannot make
59
+ * the guarantee does not implement the type.
60
+ */
61
+ export type SessionRunner = (
62
+ statements: ReadonlyArray<SessionStatement | string>,
63
+ opts?: SessionOptions,
64
+ ) => Promise<SessionResult[]>;
65
+
66
+ /** The canonical introspection search_path — explicit, override-proof, declared-matching.
67
+ *
68
+ * Every expression PostgreSQL deparses on introspection (column defaults, CHECK
69
+ * constraints, index expressions, generated columns, view bodies, function bodies)
70
+ * renders its schema qualification RELATIVE to the session `search_path`. The declared
71
+ * state is always canonical — public bare, non-public qualified — so capturing under
72
+ * `public` makes the live read comparable to it. It must be an EXPLICIT value: `RESET`
73
+ * and `SET … TO DEFAULT` inherit an `ALTER DATABASE … SET search_path` override, which
74
+ * is the very condition this exists to neutralize.
75
+ */
76
+ export const CANONICAL_SEARCH_PATH = 'public';
77
+
78
+ /**
79
+ * The options every catalog introspection reads under: one snapshot, no writes, and the
80
+ * canonical deparse baseline. One definition, so the three introspections cannot drift
81
+ * apart in what they mean by "the live state".
82
+ */
83
+ export const INTROSPECTION_SESSION: SessionOptions = {
84
+ readOnly: true,
85
+ isolation: 'repeatable read',
86
+ searchPath: CANONICAL_SEARCH_PATH,
87
+ };
88
+
89
+ /**
90
+ * A `SessionRunner` over a transaction the CALLER already opened.
91
+ *
92
+ * Some venues are inside a transaction before they introspect, and must be: the reconcile
93
+ * apply re-reads the catalog inside its own DDL transaction so provenance records the def
94
+ * hashes of what the batch actually created, not what it hoped to. Handing those a runner
95
+ * that opens its own transaction would issue a nested `BEGIN` and — worse — COMMIT the
96
+ * caller's transaction when it finished.
97
+ *
98
+ * So this borrows the open transaction instead of opening one. The session guarantee still
99
+ * holds, and holds MORE strongly: the statements share the caller's connection and its
100
+ * transaction by construction, and they can see its uncommitted writes.
101
+ *
102
+ * Two consequences, stated rather than hidden:
103
+ *
104
+ * - `isolation` and `readOnly` are NOT applied, because `SET TRANSACTION` only takes effect
105
+ * before a transaction's first statement. They are already fixed — by the caller, who
106
+ * opened the transaction and chose them. This is not a guarantee that degraded; it is the
107
+ * same guarantee supplied by the enclosing transaction.
108
+ * - The `searchPath` pin runs inside a SAVEPOINT that is ALWAYS rolled back. `SET LOCAL`
109
+ * lasts to the end of its transaction, so pinning without unwinding would silently
110
+ * re-point the rest of the caller's transaction — the reconcile apply deliberately runs
111
+ * its creates under a WIDE path, and stealing it back would be a new bug of exactly the
112
+ * shape this whole change removes. Rolling back to the savepoint undoes the SET LOCAL and
113
+ * costs nothing: every statement here is a read, and its rows are already in hand.
114
+ */
115
+ export function borrowedSessionRunner(run: (sql: string) => Promise<any[]>): SessionRunner {
116
+ return async (statements, opts) => {
117
+ const stmts = statements.map((s) => (typeof s === 'string' ? { sql: s } : s));
118
+ const results: SessionResult[] = [];
119
+ const pinned = opts?.searchPath !== undefined;
120
+ const outer = 'everystack_borrowed_session';
121
+
122
+ if (pinned) {
123
+ await run(`SAVEPOINT ${outer}`);
124
+ await run(buildSearchPathSql(String(opts!.searchPath)));
125
+ }
126
+ try {
127
+ for (let i = 0; i < stmts.length; i++) {
128
+ const stmt = stmts[i];
129
+ if (!stmt.allowFailure) {
130
+ results.push(await run(stmt.sql));
131
+ continue;
132
+ }
133
+ const inner = `everystack_borrowed_${i}`;
134
+ await run(`SAVEPOINT ${inner}`);
135
+ try {
136
+ results.push(await run(stmt.sql));
137
+ await run(`RELEASE SAVEPOINT ${inner}`);
138
+ } catch (err: any) {
139
+ await run(`ROLLBACK TO SAVEPOINT ${inner}`);
140
+ results.push({ everystackSessionError: true, message: err?.message || String(err) });
141
+ }
142
+ }
143
+ } finally {
144
+ // Unconditional: the pin must not outlive this read on ANY path, success or throw.
145
+ if (pinned) await run(`ROLLBACK TO SAVEPOINT ${outer}`);
146
+ }
147
+ return results;
148
+ };
149
+ }
150
+
151
+ /** A bare SQL identifier — the only shape a search_path element may take. */
152
+ const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
153
+
154
+ /**
155
+ * `SET LOCAL search_path` for a caller-supplied path, validated as plain identifiers. One
156
+ * definition for every venue — a path one venue pins and another rejects would be a
157
+ * difference in what "the live state" means. `$user` is deliberately not accepted: a
158
+ * role-dependent element would make a canonical capture depend on who read it.
159
+ */
160
+ export function buildSearchPathSql(searchPath: string): string {
161
+ const parts = searchPath.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
162
+ if (parts.length === 0) throw new Error('searchPath must name at least one schema');
163
+ for (const part of parts) {
164
+ if (!PLAIN_IDENT.test(part)) {
165
+ throw new Error(
166
+ `searchPath element ${JSON.stringify(part)} is not a plain identifier — a session pins only bare schema names`,
167
+ );
168
+ }
169
+ }
170
+ return `SET LOCAL search_path = ${parts.map((p) => `"${p}"`).join(', ')}`;
171
+ }
172
+
173
+ /** True when a slot carries an allowed statement's failure rather than its rows. */
174
+ export function isSessionError(slot: SessionResult | undefined): slot is SessionStatementError {
175
+ return (
176
+ !!slot && !Array.isArray(slot) && (slot as SessionStatementError).everystackSessionError === true
177
+ );
178
+ }
179
+
180
+ /** A slot's rows — an allowed failure reads as no rows. Use where absence is the answer. */
181
+ export function rowsOrEmpty(slot: SessionResult | undefined): any[] {
182
+ if (slot === undefined || isSessionError(slot)) return [];
183
+ return slot;
184
+ }