@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.
- package/package.json +2 -2
- package/src/cli/alter-type-dependents.ts +96 -0
- package/src/cli/apply-execute.ts +22 -8
- package/src/cli/authz-adoption-class.ts +75 -26
- package/src/cli/authz-canonical.ts +37 -5
- package/src/cli/authz-compile.ts +87 -37
- package/src/cli/authz-contract.ts +92 -19
- package/src/cli/authz-derive.ts +158 -33
- package/src/cli/authz-reconcile.ts +48 -6
- package/src/cli/aws.ts +32 -0
- package/src/cli/commands/db-apply.ts +60 -14
- package/src/cli/commands/db-authz.ts +9 -14
- package/src/cli/commands/db-fingerprint.ts +54 -18
- package/src/cli/commands/db-generate.ts +11 -17
- package/src/cli/commands/db-plan.ts +56 -8
- package/src/cli/commands/db-pull.ts +16 -18
- package/src/cli/commands/db-reconcile.ts +18 -20
- package/src/cli/commands/db-swap.ts +5 -4
- package/src/cli/commands/db-sync.ts +8 -5
- package/src/cli/db-build.ts +2 -2
- package/src/cli/db-source.ts +56 -0
- package/src/cli/derived-introspect.ts +27 -26
- package/src/cli/derived-lint.ts +7 -8
- package/src/cli/edge-plan.ts +112 -16
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +56 -50
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +67 -7
- package/src/cli/schema-introspect.ts +44 -17
- package/src/cli/schema-source.ts +9 -0
- package/src/cli/session.ts +184 -0
- package/src/cli/stage-read-consistency.ts +128 -0
- package/src/cli/state-apply.ts +4 -2
- package/src/cli/swap-execute.ts +4 -3
- package/src/cli/search-path.ts +0 -51
package/src/cli/model-render.ts
CHANGED
|
@@ -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
|
|
101
|
-
*
|
|
102
|
-
* `
|
|
103
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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;
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 {
|
|
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(
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
}
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -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
|
+
}
|