@everystack/cli 0.4.44 → 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 +314 -0
- package/src/cli/authz-baseline.ts +25 -3
- package/src/cli/authz-canonical.ts +178 -0
- package/src/cli/authz-compile.ts +130 -40
- package/src/cli/authz-contract.ts +212 -44
- package/src/cli/authz-derive.ts +244 -34
- package/src/cli/authz-identity.ts +222 -0
- package/src/cli/authz-ownership.ts +193 -0
- package/src/cli/authz-reconcile.ts +61 -27
- 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 +59 -15
- package/src/cli/commands/db-plan.ts +89 -9
- package/src/cli/commands/db-pull.ts +36 -19
- 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 +125 -17
- package/src/cli/git-descent.ts +16 -9
- package/src/cli/index.ts +2 -18
- package/src/cli/model-render.ts +75 -52
- package/src/cli/output.ts +25 -3
- package/src/cli/parse-flags.ts +39 -0
- package/src/cli/schema-compile.ts +6 -1
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +154 -42
- 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/git-descent.ts
CHANGED
|
@@ -32,7 +32,8 @@ import type { ModelDescriptor } from '@everystack/model';
|
|
|
32
32
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
33
33
|
import type { AuthzContract } from './authz-contract.js';
|
|
34
34
|
import { fingerprintLive } from './schema-fingerprint.js';
|
|
35
|
-
import { predictLiveFingerprint } from './edge-plan.js';
|
|
35
|
+
import { predictLiveFingerprint, governedLiveFingerprint } from './edge-plan.js';
|
|
36
|
+
import { governedRolesForModels } from './schema-fingerprint.js';
|
|
36
37
|
|
|
37
38
|
/** Compile a models barrel on disk. The default rides the CLI's runtime (tsx). */
|
|
38
39
|
export type ModelsLoader = (barrel: string) => Promise<ModelDescriptor[]>;
|
|
@@ -174,7 +175,6 @@ export async function verifyDescent(
|
|
|
174
175
|
const barrel = path.basename(modelsAbs);
|
|
175
176
|
|
|
176
177
|
const candidates = enumerateModelTrees(modelsDir, repoRoot);
|
|
177
|
-
const liveFingerprint = fingerprintLive(live.snapshot, live.contract).hash;
|
|
178
178
|
|
|
179
179
|
const nodeModules = path.join(repoRoot, 'node_modules');
|
|
180
180
|
const materializeRoot = opts.materializeRoot
|
|
@@ -187,29 +187,36 @@ export async function verifyDescent(
|
|
|
187
187
|
try {
|
|
188
188
|
for (const candidate of candidates) {
|
|
189
189
|
const dest = path.join(materializeRoot, candidate.tree);
|
|
190
|
-
let
|
|
190
|
+
let declares: boolean;
|
|
191
191
|
// A swallowed error here skips the candidate — and if it was the DECLARING tree, the
|
|
192
192
|
// verdict silently falls through to 'drift'. Real compile failures are deterministic;
|
|
193
193
|
// a transient materialize/import failure (fd pressure, module-loader contention under
|
|
194
194
|
// a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
|
|
195
195
|
// one retry, from a clean materialization, before it is recorded as a compile failure.
|
|
196
|
-
|
|
196
|
+
//
|
|
197
|
+
// The declares-test is GOVERNED on both sides, per candidate: the commit's
|
|
198
|
+
// models define the governed set, and the live hash is filtered through the
|
|
199
|
+
// SAME set as the prediction — comparing a governed prediction against the
|
|
200
|
+
// raw live hash can never match on a brownfield target with foreign roles.
|
|
201
|
+
const attempt = async (): Promise<boolean> => {
|
|
197
202
|
if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
|
|
198
203
|
const models = await loader(path.join(dest, barrel));
|
|
199
|
-
|
|
204
|
+
const governedRoles = governedRolesForModels(models);
|
|
205
|
+
const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema, governedRoles });
|
|
206
|
+
return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
|
|
200
207
|
};
|
|
201
208
|
try {
|
|
202
209
|
try {
|
|
203
|
-
|
|
210
|
+
declares = await attempt();
|
|
204
211
|
} catch {
|
|
205
212
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
206
|
-
|
|
213
|
+
declares = await attempt();
|
|
207
214
|
}
|
|
208
215
|
} catch (err: any) {
|
|
209
216
|
compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
|
|
210
217
|
continue;
|
|
211
218
|
}
|
|
212
|
-
if (
|
|
219
|
+
if (!declares) continue;
|
|
213
220
|
|
|
214
221
|
for (const commit of candidate.commits) {
|
|
215
222
|
try {
|
|
@@ -255,7 +262,7 @@ export async function verifyDescent(
|
|
|
255
262
|
scannedTrees: candidates.length,
|
|
256
263
|
compileFailures,
|
|
257
264
|
reason:
|
|
258
|
-
`no committed models state declares the target's live fingerprint ${short(
|
|
265
|
+
`no committed models state declares the target's live fingerprint ${short(fingerprintLive(live.snapshot, live.contract).hash)} — ` +
|
|
259
266
|
`scanned ${candidates.length} historical tree(s) across all refs${failureNote}. ` +
|
|
260
267
|
'Either the database was hand-edited (drift) or the declaring history was rewritten. ' +
|
|
261
268
|
'Align the models with the live database first (db:pull, commit), ' +
|
package/src/cli/index.ts
CHANGED
|
@@ -45,27 +45,11 @@ import { auditCommand } from './commands/audit.js';
|
|
|
45
45
|
import { uiAuditCommand } from './commands/ui-audit.js';
|
|
46
46
|
import { runbookCommand } from './commands/runbook.js';
|
|
47
47
|
import { fail } from './output.js';
|
|
48
|
+
import { parseFlags } from './parse-flags.js';
|
|
48
49
|
|
|
49
50
|
const args = process.argv.slice(2);
|
|
50
51
|
const command = args[0];
|
|
51
52
|
|
|
52
|
-
function parseFlags(args: string[]): Record<string, string> {
|
|
53
|
-
const flags: Record<string, string> = {};
|
|
54
|
-
for (let i = 0; i < args.length; i++) {
|
|
55
|
-
// Handle both --flag and -flag
|
|
56
|
-
if (args[i].startsWith('--')) {
|
|
57
|
-
const key = args[i].slice(2);
|
|
58
|
-
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
|
|
59
|
-
flags[key] = value;
|
|
60
|
-
} else if (args[i].startsWith('-') && args[i].length > 1) {
|
|
61
|
-
const key = args[i].slice(1);
|
|
62
|
-
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
|
|
63
|
-
flags[key] = value;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return flags;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
/**
|
|
70
54
|
* Auto-detect HOST_URL from SST outputs.
|
|
71
55
|
* SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
|
|
@@ -383,7 +367,7 @@ Usage:
|
|
|
383
367
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
384
368
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
385
369
|
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
|
386
|
-
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always +
|
|
370
|
+
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
|
|
387
371
|
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
388
372
|
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
389
373
|
everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
|
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). */
|
|
@@ -213,8 +215,25 @@ function bareName(table: string): string {
|
|
|
213
215
|
return table.replace(/^[^.]+\./, '');
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Migration infrastructure a pull must never render as a model, whatever schema it landed in.
|
|
220
|
+
*
|
|
221
|
+
* A brownfield pull adopts a database another migration tool still owns, and that tool's
|
|
222
|
+
* bookkeeping is not the app's data. Rendering it as a model makes everystack GOVERN it: the
|
|
223
|
+
* compiled contract declares an admin policy, and the adopter's first plan carries a
|
|
224
|
+
* `CREATE POLICY` on a table nobody asked us to manage. The journal belongs to whoever writes
|
|
225
|
+
* it — drizzle-kit's `__drizzle_migrations`, Rails' `schema_migrations` +
|
|
226
|
+
* `ar_internal_metadata`.
|
|
227
|
+
*
|
|
228
|
+
* Deliberately a SHORT list of names that are unambiguously a migration tool's own state. It
|
|
229
|
+
* is not a heuristic and must not become one: a real app table wrongly matched here silently
|
|
230
|
+
* loses its declared authorization.
|
|
231
|
+
*/
|
|
232
|
+
const INFRASTRUCTURE_TABLES = new Set([
|
|
233
|
+
'__drizzle_migrations', // drizzle-kit
|
|
234
|
+
'schema_migrations', // Rails / ActiveRecord
|
|
235
|
+
'ar_internal_metadata', // Rails / ActiveRecord
|
|
236
|
+
]);
|
|
218
237
|
|
|
219
238
|
/**
|
|
220
239
|
* The tables a pull renders: the requested schema, minus migration infrastructure.
|
|
@@ -374,8 +393,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
374
393
|
?? (enumValues
|
|
375
394
|
? `field.enum(${tsLiteral(col.type)}, [${enumValues.map(tsLiteral).join(', ')}])`
|
|
376
395
|
: fieldFactoryCall(col.type));
|
|
377
|
-
|
|
378
|
-
|
|
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`;
|
|
379
401
|
|
|
380
402
|
const isPk = table.primaryKey.includes(col.name);
|
|
381
403
|
if (isPk) expr += '.primaryKey()';
|
|
@@ -486,9 +508,10 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
486
508
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
487
509
|
// put it, proving the position is mechanical-edit-friendly).
|
|
488
510
|
const stanza = abilitiesStanza(abilities, table, liveAuthz);
|
|
511
|
+
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
489
512
|
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
490
513
|
|
|
491
|
-
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});`;
|
|
492
515
|
}
|
|
493
516
|
|
|
494
517
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
package/src/cli/output.ts
CHANGED
|
@@ -11,11 +11,11 @@ export function step(msg: string): void {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function success(msg: string): void {
|
|
14
|
-
|
|
14
|
+
say(` \u2713 ${msg}`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function warn(msg: string): void {
|
|
18
|
-
|
|
18
|
+
say(` ! ${msg}`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function fail(msg: string): void {
|
|
@@ -23,5 +23,27 @@ export function fail(msg: string): void {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function info(msg: string): void {
|
|
26
|
-
|
|
26
|
+
say(` ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* When a command makes stdout the DATA channel (`db:plan --out -`), every human-readable
|
|
31
|
+
* line has to move to stderr \u2014 otherwise the report is interleaved with the artifact and
|
|
32
|
+
* the stdout form cannot be piped to anything. `step` and `fail` already write to stderr
|
|
33
|
+
* on that principle; this extends it to the rest, on demand.
|
|
34
|
+
*
|
|
35
|
+
* Off by default, so no command's output moves unless it asks. One-way and process-wide:
|
|
36
|
+
* a CLI process runs exactly one command, and the choice is made once, before it prints.
|
|
37
|
+
*/
|
|
38
|
+
let stdoutReserved = false;
|
|
39
|
+
|
|
40
|
+
/** Declare stdout the data channel: human-readable output moves to stderr from here on. */
|
|
41
|
+
export function reserveStdoutForData(): void {
|
|
42
|
+
stdoutReserved = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Human-readable output \u2014 stdout normally, stderr once stdout is reserved for data. */
|
|
46
|
+
function say(line: string): void {
|
|
47
|
+
if (stdoutReserved) console.error(line);
|
|
48
|
+
else console.log(line);
|
|
27
49
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's flag parser.
|
|
3
|
+
*
|
|
4
|
+
* `--key value` / `-k value`, with a value-less flag defaulting to the string `'true'`.
|
|
5
|
+
* Extracted from the entry point so it can be tested without executing the CLI.
|
|
6
|
+
*
|
|
7
|
+
* The subtlety is what counts as "the next argument is a VALUE, not the next flag".
|
|
8
|
+
* Two legitimate values look like flags, or like nothing, to a naive check:
|
|
9
|
+
*
|
|
10
|
+
* - A bare `-` is the conventional stdout sentinel. `db:plan --out -` documents itself
|
|
11
|
+
* as "print to stdout"; read as a value-less flag it became `out=true` and the
|
|
12
|
+
* command wrote a file literally named `true`.
|
|
13
|
+
* - The EMPTY string is a value. `db:approvers --set ''` is the documented way to
|
|
14
|
+
* declare an empty approver set and DISABLE destructive applies on a stage; read as
|
|
15
|
+
* value-less it became `set=true`, and the command refused with "--set needs a
|
|
16
|
+
* value" — so the documented way to turn the gate off could not be typed.
|
|
17
|
+
*
|
|
18
|
+
* Both are the same mistake: treating an unusual-looking value as an absent one. A flag
|
|
19
|
+
* is an argument that starts with `-` AND is longer than one character; everything else,
|
|
20
|
+
* including `''`, is a value.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Is this argument a flag (as opposed to a value)? A bare `-` is a value. */
|
|
24
|
+
function isFlag(arg: string): boolean {
|
|
25
|
+
return arg.startsWith('-') && arg.length > 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse `--key value` / `-k value` pairs; a flag with no value becomes `'true'`. */
|
|
29
|
+
export function parseFlags(args: string[]): Record<string, string> {
|
|
30
|
+
const flags: Record<string, string> = {};
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
if (!isFlag(arg)) continue;
|
|
34
|
+
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
35
|
+
const next = args[i + 1];
|
|
36
|
+
flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';
|
|
37
|
+
}
|
|
38
|
+
return flags;
|
|
39
|
+
}
|
|
@@ -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
|
}
|
|
@@ -46,12 +46,14 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
import { createHash } from 'node:crypto';
|
|
49
|
+
import { canonicalAuthz } from './authz-canonical.js';
|
|
49
50
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
50
51
|
import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
|
|
51
52
|
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
52
53
|
import { compileTableSchema, compileEnums, compileSequences } from './schema-compile.js';
|
|
53
54
|
import { compileTableContract } from './authz-compile.js';
|
|
54
|
-
import {
|
|
55
|
+
import { governedRoleSet } from './authz-reconcile.js';
|
|
56
|
+
import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
|
|
55
57
|
|
|
56
58
|
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
57
59
|
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
@@ -62,7 +64,53 @@ import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
|
62
64
|
// STANDALONE SEQUENCES enter the canonical form (declared via defineSequence,
|
|
63
65
|
// introspected from pg_sequence minus serial-owned) — a coverage expansion; the
|
|
64
66
|
// `sequences` key appears only when any exist, so sequence-free states hash unchanged.
|
|
65
|
-
|
|
67
|
+
// v4: the AUTHZ canonical form became the reconciler's equivalence relation instead of a
|
|
68
|
+
// name-keyed transcript of the catalog. Policy NAMES leave the hash (a brownfield database
|
|
69
|
+
// names its policies whatever its previous migration tool named them, so hashing the name made
|
|
70
|
+
// a policy carrying the declared authorization read as a different state); policies expand
|
|
71
|
+
// across their roles as a MULTISET (so one policy TO a,b hashes equal to two identical ones TO
|
|
72
|
+
// a and TO b, and duplicates never collapse); PUBLIC stays a single sentinel and is never
|
|
73
|
+
// enumerated; and grants are filtered to the GOVERNED grantees, because the reconciler leaves
|
|
74
|
+
// an ungoverned migrator/ETL role alone and a hash that counts it can never converge.
|
|
75
|
+
// Together these restore the identity the format exists for: fingerprints match exactly when
|
|
76
|
+
// db:generate is a no-op.
|
|
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;
|
|
66
114
|
|
|
67
115
|
// ---------------------------------------------------------------------------
|
|
68
116
|
// Canonical form.
|
|
@@ -107,48 +155,28 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
|
|
|
107
155
|
})),
|
|
108
156
|
(f) => stableStringify(f),
|
|
109
157
|
),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(),
|
|
115
165
|
};
|
|
116
166
|
}
|
|
117
167
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
Object.entries(contract.columnGrants)
|
|
131
|
-
.map(([role, byPriv]) => [
|
|
132
|
-
role,
|
|
133
|
-
Object.fromEntries(
|
|
134
|
-
Object.entries(byPriv)
|
|
135
|
-
.map(([priv, cols]) => [priv, [...cols].sort()] as const)
|
|
136
|
-
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
137
|
-
),
|
|
138
|
-
] as const)
|
|
139
|
-
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
140
|
-
),
|
|
141
|
-
}
|
|
142
|
-
: {}),
|
|
143
|
-
policies: byKey(
|
|
144
|
-
contract.policies.map((p) => ({
|
|
145
|
-
name: p.name, command: p.command, roles: [...p.roles].sort(),
|
|
146
|
-
permissive: p.permissive, using: p.using, check: p.check,
|
|
147
|
-
})),
|
|
148
|
-
(p) => p.name,
|
|
149
|
-
),
|
|
150
|
-
// The handler-side columns exposure block is NOT a database fact — excluded.
|
|
151
|
-
};
|
|
168
|
+
/**
|
|
169
|
+
* The authorization slice of the canonical form.
|
|
170
|
+
*
|
|
171
|
+
* Delegates to `authz-canonical`, which the reconciler and the differ read too. Keeping a
|
|
172
|
+
* private copy here is what let the fingerprint drift out of step with the reconciler and
|
|
173
|
+
* report permanent drift on a database that had nothing to reconcile.
|
|
174
|
+
*/
|
|
175
|
+
function canonicalAuthzTable(
|
|
176
|
+
contract: TableContract,
|
|
177
|
+
governed?: ReadonlySet<string>,
|
|
178
|
+
): Record<string, unknown> {
|
|
179
|
+
return canonicalAuthz(contract, governed);
|
|
152
180
|
}
|
|
153
181
|
|
|
154
182
|
export interface CanonicalState {
|
|
@@ -171,6 +199,17 @@ export interface CanonicalizeOptions {
|
|
|
171
199
|
/** Restrict the state to these schemas — the content address of ONE schema (e.g. a
|
|
172
200
|
* schema-scoped artifact) instead of the whole database. Omitted = every schema. */
|
|
173
201
|
schemas?: string[];
|
|
202
|
+
/**
|
|
203
|
+
* The grantees the models govern. PASS THIS WHENEVER HASHING A LIVE CONTRACT.
|
|
204
|
+
*
|
|
205
|
+
* The reconciler leaves an ungoverned grantee alone by design, so a live grant to a
|
|
206
|
+
* migrator or ETL role is never reconciled. A hash that counts it describes a state the
|
|
207
|
+
* models can never reach: the operator gets "nothing to do" from the plan and "you have
|
|
208
|
+
* drifted" from the gate, with no action in between that resolves it. Omitted means "hash
|
|
209
|
+
* every grantee", which is correct only for a contract compiled FROM the models, where
|
|
210
|
+
* every grantee is governed by construction.
|
|
211
|
+
*/
|
|
212
|
+
governedRoles?: ReadonlySet<string>;
|
|
174
213
|
}
|
|
175
214
|
|
|
176
215
|
export function canonicalizeState(
|
|
@@ -208,7 +247,10 @@ export function canonicalizeState(
|
|
|
208
247
|
.map((e) => ({ name: e.name, values: e.values })),
|
|
209
248
|
(e) => e.name,
|
|
210
249
|
),
|
|
211
|
-
authz: byKey(
|
|
250
|
+
authz: byKey(
|
|
251
|
+
authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzTable(t, opts.governedRoles)),
|
|
252
|
+
(t) => String(t.table),
|
|
253
|
+
),
|
|
212
254
|
...(sequences.length ? { sequences } : {}),
|
|
213
255
|
};
|
|
214
256
|
}
|
|
@@ -248,6 +290,27 @@ export function fingerprintModels(
|
|
|
248
290
|
return fingerprintState(snapshot, authzTables, opts.schemas ? { schemas: opts.schemas } : {});
|
|
249
291
|
}
|
|
250
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
|
+
|
|
251
314
|
/** Convenience: the live side, from the two existing introspections. */
|
|
252
315
|
export function fingerprintLive(
|
|
253
316
|
snapshot: SchemaSnapshot,
|
|
@@ -305,3 +368,52 @@ export interface UnfingerprintedObject {
|
|
|
305
368
|
export function mapUnfingerprintedRows(rows: Array<{ kind: unknown; identity: unknown }>): UnfingerprintedObject[] {
|
|
306
369
|
return rows.map((r) => ({ kind: String(r.kind), identity: String(r.identity) }));
|
|
307
370
|
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Comparing a STORED fingerprint against live reality.
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A fingerprint as recorded in an artifact — a plan, a baseline, an export stamp.
|
|
379
|
+
*
|
|
380
|
+
* `v` is the format it was computed under. It must be stored ALONGSIDE the hash, not merely
|
|
381
|
+
* mixed into it: a hash alone cannot say why it differs, and the difference between "you have
|
|
382
|
+
* drifted" and "the format changed" is the difference between an operator hunting a phantom
|
|
383
|
+
* change and an operator running one re-baseline.
|
|
384
|
+
*/
|
|
385
|
+
export interface StoredFingerprint {
|
|
386
|
+
hash: string;
|
|
387
|
+
/** The FINGERPRINT_VERSION in force when the hash was computed. Absent = pre-v4 artifact. */
|
|
388
|
+
v?: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export type FingerprintComparison =
|
|
392
|
+
| { kind: 'match' }
|
|
393
|
+
| { kind: 'drift' }
|
|
394
|
+
| { kind: 'format-changed'; stored: number | 'unstamped'; current: number };
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Compare a stored fingerprint to one computed now.
|
|
398
|
+
*
|
|
399
|
+
* A raw hash compare across formats reports DRIFT, which is a lie — the state may be
|
|
400
|
+
* untouched while the way we describe it changed. Every hash comparison that spans an artifact
|
|
401
|
+
* boundary must come through here so a format change reads as a format change.
|
|
402
|
+
*/
|
|
403
|
+
export function compareStoredFingerprint(
|
|
404
|
+
stored: StoredFingerprint,
|
|
405
|
+
currentHash: string,
|
|
406
|
+
currentVersion: number = FINGERPRINT_VERSION,
|
|
407
|
+
): FingerprintComparison {
|
|
408
|
+
if (stored.v !== currentVersion) {
|
|
409
|
+
return { kind: 'format-changed', stored: stored.v ?? 'unstamped', current: currentVersion };
|
|
410
|
+
}
|
|
411
|
+
return stored.hash === currentHash ? { kind: 'match' } : { kind: 'drift' };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The operator-facing sentence for a format change. Names the fix, never the phantom drift. */
|
|
415
|
+
export function formatChangedMessage(c: Extract<FingerprintComparison, { kind: 'format-changed' }>): string {
|
|
416
|
+
return `fingerprint format changed (recorded v${c.stored}, current v${c.current}) — this is NOT drift. `
|
|
417
|
+
+ 'The artifact predates the current canonical form, so its hash cannot be compared. '
|
|
418
|
+
+ 'Re-mint the plan (db:plan) or re-baseline the stage (db:reconcile --rebaseline); no DDL is involved.';
|
|
419
|
+
}
|