@everystack/cli 0.4.48 → 0.4.50
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/authz-derive.ts +110 -9
- package/src/cli/authz-lint.ts +61 -0
- package/src/cli/commands/db-check.ts +12 -3
- package/src/cli/commands/db-pull.ts +16 -2
- package/src/cli/derived-compile.ts +81 -12
- package/src/cli/derived-grants.ts +10 -3
- package/src/cli/derived-introspect.ts +126 -11
- package/src/cli/derived-lint.ts +95 -5
- package/src/cli/derived-render.ts +203 -33
- package/src/cli/index.ts +1 -1
- package/src/cli/parse-flags.ts +21 -1
- package/src/plugin.ts +23 -5
|
@@ -18,6 +18,7 @@ import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introsp
|
|
|
18
18
|
import { parseIndexDefinition } from './schema-introspect.js';
|
|
19
19
|
import { modelFileName, renderFieldLines } from './model-render.js';
|
|
20
20
|
import { splitFunctionIdentity } from './pg-argtypes.js';
|
|
21
|
+
import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive.js';
|
|
21
22
|
|
|
22
23
|
export interface DerivedRenderResult {
|
|
23
24
|
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
@@ -77,26 +78,61 @@ function tsTemplate(body: string): string {
|
|
|
77
78
|
return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/** An object key, bare when it is a valid identifier and quoted when it is not — a role
|
|
82
|
+
* name is a PostgreSQL identifier and may hold characters JavaScript will not take bare. */
|
|
83
|
+
function tsKey(s: string): string {
|
|
84
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : tsString(s);
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
function tsString(s: string): string {
|
|
81
88
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Split a relation's live ACL into what `abilities` can SAY and what must be RECORDED.
|
|
93
|
+
*
|
|
94
|
+
* A grantee whose privileges are exactly `SELECT`, and which is a named app role, is an
|
|
95
|
+
* intended read audience — `can('read')`. Everything else is fact without intent:
|
|
96
|
+
* `PUBLIC` (not a role the ability vocabulary addresses) and any privilege beyond SELECT
|
|
97
|
+
* (write grants, REFERENCES/TRIGGER/TRUNCATE). Those become `privileges`.
|
|
98
|
+
*
|
|
99
|
+
* Splitting rather than rejecting is the whole of R1: previously ANY inexpressible grant
|
|
100
|
+
* discarded the entire object, body included.
|
|
101
|
+
*/
|
|
102
|
+
function splitRelationGrants(
|
|
103
|
+
grants: Record<string, string[]>,
|
|
104
|
+
): { abilities: string[]; privileges: Record<string, string[]>; foreign: string[] } {
|
|
105
|
+
const readRoles = new Set<string>();
|
|
106
|
+
const privileges: Record<string, string[]> = {};
|
|
107
|
+
const foreign: string[] = [];
|
|
108
|
+
for (const [grantee, privs] of Object.entries(grants)) {
|
|
109
|
+
const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
|
|
110
|
+
// ABILITIES are limited to the compiler's own vocabulary (authz-derive's KNOWN_ROLES).
|
|
111
|
+
// A foreign grantee is FACT, not opinion, so it is RECORDED — never declared as an
|
|
112
|
+
// intended audience. The views path used to render any select-only grantee as an
|
|
113
|
+
// ability, so one pull could declare a role as an ABILITY on a view while the table
|
|
114
|
+
// recorded it as a foreign grantee, and the reachability gate then demanded table-side
|
|
115
|
+
// reach the tables path will never render. The contract contradicted itself.
|
|
116
|
+
//
|
|
117
|
+
// RECORDED, not dropped — and that is the part the obvious fix gets wrong. On a TABLE,
|
|
118
|
+
// "not rendered" is safe because the table reconciler leaves an ungoverned grantee
|
|
119
|
+
// alone. On a DERIVED object there is no such carve-out: diffObjectGrants unions the
|
|
120
|
+
// declared and live grantees, so a grantee we omit is a grantee we REVOKE. Measured:
|
|
121
|
+
// declared {anon,authenticated} against live +outbound_migrator plans
|
|
122
|
+
// `REVOKE SELECT, UPDATE ON public.v FROM outbound_migrator`. Dropping them would
|
|
123
|
+
// reintroduce the silent revoke this whole branch exists to prevent.
|
|
124
|
+
if (!GOVERNED_VOCABULARY.has(grantee)) foreign.push(grantee);
|
|
125
|
+
if (selectOnly && KNOWN_ROLES.has(grantee)) readRoles.add(grantee);
|
|
126
|
+
else privileges[grantee] = [...privs].sort();
|
|
90
127
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
set.delete('authenticated');
|
|
128
|
+
const abilities: string[] = [];
|
|
129
|
+
if (readRoles.has('anon') && readRoles.has('authenticated')) {
|
|
130
|
+
abilities.push(`can('read')`);
|
|
131
|
+
readRoles.delete('anon');
|
|
132
|
+
readRoles.delete('authenticated');
|
|
97
133
|
}
|
|
98
|
-
for (const role of [...
|
|
99
|
-
return
|
|
134
|
+
for (const role of [...readRoles].sort()) abilities.push(`can('read', { role: ${tsString(role)} })`);
|
|
135
|
+
return { abilities, privileges, foreign: foreign.sort() };
|
|
100
136
|
}
|
|
101
137
|
|
|
102
138
|
/**
|
|
@@ -202,6 +238,32 @@ function renderTableIndexBuilder(indexdef: string): string | null {
|
|
|
202
238
|
return s;
|
|
203
239
|
}
|
|
204
240
|
|
|
241
|
+
/**
|
|
242
|
+
* A collision-free variable for a trigger binding, chosen deterministically so a re-pull of
|
|
243
|
+
* an unchanged database is byte-stable. In order: the bare name, then `<name>Trigger`, then
|
|
244
|
+
* owner-qualified, then owner-qualified + `Trigger`. The last step cannot collide —
|
|
245
|
+
* a trigger name is unique within its table, so table + name is unique in the schema.
|
|
246
|
+
*/
|
|
247
|
+
function uniqueVar(base: string, o: LiveObject, used: Set<string>): string {
|
|
248
|
+
const ownerName = (o.table ?? '').split('.').pop() ?? '';
|
|
249
|
+
for (const candidate of [
|
|
250
|
+
base,
|
|
251
|
+
`${base}Trigger`,
|
|
252
|
+
toCamelCase(`${ownerName}_${o.name}`),
|
|
253
|
+
toCamelCase(`${ownerName}_${o.name}_trigger`),
|
|
254
|
+
]) {
|
|
255
|
+
if (!used.has(candidate)) {
|
|
256
|
+
used.add(candidate);
|
|
257
|
+
return candidate;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Unreachable on a real catalog; a counter beats throwing on a name.
|
|
261
|
+
let i = 2;
|
|
262
|
+
while (used.has(`${base}Trigger${i}`)) i += 1;
|
|
263
|
+
used.add(`${base}Trigger${i}`);
|
|
264
|
+
return `${base}Trigger${i}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
205
267
|
export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
|
|
206
268
|
const warnings: string[] = [];
|
|
207
269
|
const lines: string[] = [];
|
|
@@ -227,10 +289,12 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
227
289
|
|
|
228
290
|
// -- emission order: dependencies first (Kahn over derived→derived edges) ---
|
|
229
291
|
|
|
292
|
+
// Triggers are held out of the topological pass and emitted LAST (see the trigger block
|
|
293
|
+
// at the end). A binding needs BOTH its target and its execute function to already
|
|
294
|
+
// exist, and the catalog records no trigger→function edge, so file order — not the
|
|
295
|
+
// dependency sort — is what guarantees it.
|
|
230
296
|
const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
|
|
231
|
-
|
|
232
|
-
warnings.push(`${t.identity}: live trigger not rendered — declare it on its model with trigger() (v1 renders relations, functions, and sequences).`);
|
|
233
|
-
}
|
|
297
|
+
const triggers = catalog.objects.filter((o) => o.kind === 'trigger');
|
|
234
298
|
const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
|
|
235
299
|
// Var names carry the schema for non-public objects — two schemas can share a bare name.
|
|
236
300
|
// OVERLOADS share everything but their argument types, so a bare name would emit two
|
|
@@ -302,22 +366,42 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
302
366
|
const { refs, fixmes } = depsFor(o.identity);
|
|
303
367
|
|
|
304
368
|
if (o.kind === 'view' || o.kind === 'materialized view') {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
369
|
+
// The live ACL splits in two: the part `can('read')` can SAY (plain SELECT to named
|
|
370
|
+
// app roles) becomes abilities, and everything else is RECORDED as `privileges`.
|
|
371
|
+
//
|
|
372
|
+
// This used to skip the object outright — definition and all — whenever any grant
|
|
373
|
+
// was inexpressible, which on a real brownfield schema is every relation it has:
|
|
374
|
+
// three roles hold the blanket DELETE/INSERT/REFERENCES/SELECT/TRIGGER/TRUNCATE/
|
|
375
|
+
// UPDATE that `GRANT ALL ON ALL TABLES IN SCHEMA` leaves behind, and PUBLIC holds
|
|
376
|
+
// SELECT on a few. The adopter got a FIXME instead of their view.
|
|
377
|
+
//
|
|
378
|
+
// Recording is also what makes rendering SAFE, and it is why the silent-REVOKE
|
|
379
|
+
// question that blocked this no longer needs an answer: emitting the view with only
|
|
380
|
+
// the abilities we can express would leave the write grants undeclared, so the next
|
|
381
|
+
// generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
|
|
382
|
+
// Declared == live means nothing is planned at all.
|
|
383
|
+
const { abilities, privileges, foreign } = splitRelationGrants(o.grants ?? {});
|
|
384
|
+
if (foreign.length) {
|
|
385
|
+
warnings.push(
|
|
386
|
+
`${o.identity}: grants exist for ${foreign.join(', ')} — recorded as \`privileges\`, not declared as ` +
|
|
387
|
+
'abilities. Abilities name the roles the model governs (anon/authenticated/admin); a grantee outside ' +
|
|
388
|
+
'that vocabulary is fact, not an intended audience. Recorded rather than dropped because a derived ' +
|
|
389
|
+
'object REVOKES any live grantee the declaration omits.',
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
const writeGrants = Object.values(privileges).some((ps) =>
|
|
393
|
+
ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
|
|
394
|
+
);
|
|
395
|
+
if (writeGrants) {
|
|
308
396
|
// A view carrying INSERT/UPDATE/DELETE almost never means someone intended a
|
|
309
397
|
// writable view — it usually traces to a blanket `GRANT ALL ON ALL TABLES IN
|
|
310
398
|
// SCHEMA public` in an old migration, which sweeps views up with the tables.
|
|
311
399
|
// Naming that here saves the diagnosis; an adopter paid for it once already.
|
|
312
|
-
const
|
|
313
|
-
|
|
400
|
+
const grantText = Object.entries(privileges).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
|
|
401
|
+
warnings.push(
|
|
402
|
+
`${o.identity}: write privileges (${grantText}) recorded as \`privileges\` — the object is declared and nothing will be revoked. ` +
|
|
403
|
+
'Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first, then revoke and re-pull.',
|
|
314
404
|
);
|
|
315
|
-
const hint = writeGrants
|
|
316
|
-
? ' Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first.'
|
|
317
|
-
: '';
|
|
318
|
-
warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.${hint}`);
|
|
319
|
-
lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
|
|
320
|
-
continue;
|
|
321
405
|
}
|
|
322
406
|
|
|
323
407
|
// --matviews-as-tables (the gap-B consumer flip): the matview renders as a
|
|
@@ -359,7 +443,13 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
359
443
|
lines.push(
|
|
360
444
|
...fixmes,
|
|
361
445
|
`export const ${varName} = defineMaterializedTable(${tsString(o.name)}, {`,
|
|
362
|
-
|
|
446
|
+
// Same split as the relation path: a model carries `privileges` too, so the
|
|
447
|
+
// inexpressible grants are recorded rather than dropped on this branch either.
|
|
448
|
+
...(abilities.length ? [` abilities: [${abilities.join(', ')}],`]
|
|
449
|
+
: Object.keys(privileges).length ? [] : [' private: true,']),
|
|
450
|
+
...(Object.keys(privileges).length
|
|
451
|
+
? [` privileges: { ${Object.keys(privileges).sort().map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`).join(', ')} },`]
|
|
452
|
+
: []),
|
|
363
453
|
...(o.schema !== 'public' ? [` schema: ${tsString(o.schema)},`] : []),
|
|
364
454
|
' fields: {',
|
|
365
455
|
...fieldLines,
|
|
@@ -377,12 +467,21 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
377
467
|
}
|
|
378
468
|
|
|
379
469
|
const props: string[] = [];
|
|
470
|
+
const hasPrivileges = Object.keys(privileges).length > 0;
|
|
380
471
|
if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
|
|
381
|
-
|
|
382
|
-
|
|
472
|
+
// `private: true` means "declared dark, no grants" — it contradicts a recorded
|
|
473
|
+
// grant, and the model refuses the pair. A relation whose only reach is recorded
|
|
474
|
+
// (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
|
|
475
|
+
if (abilities.length === 0 && !hasPrivileges) props.push('private: true,');
|
|
476
|
+
else if (abilities.length) {
|
|
383
477
|
props.push(`abilities: [${abilities.join(', ')}],`);
|
|
384
478
|
need('can');
|
|
385
479
|
}
|
|
480
|
+
if (hasPrivileges) {
|
|
481
|
+
const entries = Object.keys(privileges).sort()
|
|
482
|
+
.map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`);
|
|
483
|
+
props.push(`privileges: { ${entries.join(', ')} },`);
|
|
484
|
+
}
|
|
386
485
|
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
387
486
|
if (o.kind === 'materialized view') {
|
|
388
487
|
const builders = o.indexes.map((def) => {
|
|
@@ -458,6 +557,11 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
458
557
|
props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
|
|
459
558
|
need('can');
|
|
460
559
|
}
|
|
560
|
+
// A live PUBLIC EXECUTE is RECORDED, not converted into an ability. Rendering it as
|
|
561
|
+
// can('execute', { role: … }) would invent an audience the database never named, and
|
|
562
|
+
// dropping it silently would make the descriptor plan a REVOKE on adoption. Both are
|
|
563
|
+
// the adopter's database being changed to suit our vocabulary.
|
|
564
|
+
if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
|
|
461
565
|
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
462
566
|
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
463
567
|
props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
|
|
@@ -465,8 +569,11 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
465
569
|
need('sql');
|
|
466
570
|
lines.push(
|
|
467
571
|
...fixmes,
|
|
468
|
-
|
|
469
|
-
|
|
572
|
+
// Not a FIXME any more — `privileges: { PUBLIC: ['EXECUTE'] }` above says it, and
|
|
573
|
+
// adopting the descriptor no longer applies a revoke. It is still worth a look on a
|
|
574
|
+
// SECURITY DEFINER function, which is what db:check now WARNs about.
|
|
575
|
+
...(publicExec && f.secdef
|
|
576
|
+
? [`// NOTE: ${o.identity} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
|
|
470
577
|
: []),
|
|
471
578
|
`export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
|
|
472
579
|
...props.map((p) => ` ${p}`),
|
|
@@ -476,6 +583,69 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
476
583
|
names.push(varName);
|
|
477
584
|
}
|
|
478
585
|
|
|
586
|
+
// -- trigger bindings, last -------------------------------------------------
|
|
587
|
+
//
|
|
588
|
+
// Every identifier the file has already exported. A trigger's variable must dodge all of
|
|
589
|
+
// them, not just other triggers.
|
|
590
|
+
const usedVars = new Set<string>([...names, ...sequenceNames, ...materializedTableNames]);
|
|
591
|
+
//
|
|
592
|
+
// One descriptor per BINDING, never per function. The two are many-to-one — on the
|
|
593
|
+
// reference schema 18 bindings share 11 functions, `_touch_updated_at()` alone serving
|
|
594
|
+
// five tables — so a renderer keyed on the function would silently emit 11 and lose the
|
|
595
|
+
// rest. The target may be a MODEL (imported) or a derived VIEW (declared above).
|
|
596
|
+
for (const o of [...triggers].sort((a, b) => a.identity.localeCompare(b.identity))) {
|
|
597
|
+
const owner = o.ownerIdentity ?? (o.table?.includes('.') ? o.table : `${o.schema}.${o.table ?? ''}`);
|
|
598
|
+
const modelVar = opts.knownTables.get(owner);
|
|
599
|
+
const targetVar = modelVar ?? varOf.get(owner);
|
|
600
|
+
if (!o.trg || !targetVar) {
|
|
601
|
+
warnings.push(
|
|
602
|
+
`${o.identity}: trigger not rendered — ${!o.trg
|
|
603
|
+
? 'the introspection carried no structured binding (pre-R2 catalog read)'
|
|
604
|
+
: `its target '${owner}' is neither a known model nor a rendered view`}.`,
|
|
605
|
+
);
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// The function is matched by identity. Trigger functions take no arguments, so the
|
|
609
|
+
// catalog's signature is always `()` — no overload can exist to disambiguate.
|
|
610
|
+
const fnVar = varOf.get(`${o.trg.functionIdentity}()`);
|
|
611
|
+
if (!fnVar) {
|
|
612
|
+
warnings.push(`${o.identity}: trigger not rendered — its function '${o.trg.functionIdentity}' is not in the rendered set.`);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
// Registered only once the binding actually renders — an import emitted for a skipped
|
|
616
|
+
// trigger would be an unused name in generated code.
|
|
617
|
+
if (modelVar) modelRefs.add(owner);
|
|
618
|
+
// A trigger's name collides freely: with its OWN function (`update_users_tsvector` is
|
|
619
|
+
// both, on the reference schema) and with a same-named trigger on another table
|
|
620
|
+
// (trigger names are unique per table, not per schema). Either collision emits two
|
|
621
|
+
// `export const X` — the file does not compile, and `execute: X` would bind the
|
|
622
|
+
// trigger to itself. Suffix only on collision, so an uncontended schema renders
|
|
623
|
+
// byte-identically to a name-per-object scheme.
|
|
624
|
+
const varName = uniqueVar(
|
|
625
|
+
toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`),
|
|
626
|
+
o,
|
|
627
|
+
usedVars,
|
|
628
|
+
);
|
|
629
|
+
const props = [
|
|
630
|
+
`on: ${targetVar},`,
|
|
631
|
+
`timing: ${tsString(o.trg.shape.timing)},`,
|
|
632
|
+
`events: [${o.trg.shape.events.map(tsString).join(', ')}],`,
|
|
633
|
+
`forEach: ${tsString(o.trg.shape.forEach)},`,
|
|
634
|
+
...(o.trg.updateOf?.length ? [`of: [${o.trg.updateOf.map(tsString).join(', ')}],`] : []),
|
|
635
|
+
...(o.trg.when ? [`condition: sql\`${tsTemplate(o.trg.when)}\`,`] : []),
|
|
636
|
+
`execute: ${fnVar},`,
|
|
637
|
+
];
|
|
638
|
+
if (o.trg.when) need('sql');
|
|
639
|
+
need('defineTrigger');
|
|
640
|
+
lines.push(
|
|
641
|
+
`export const ${varName} = defineTrigger(${tsString(o.name)}, {`,
|
|
642
|
+
...props.map((p) => ` ${p}`),
|
|
643
|
+
'});',
|
|
644
|
+
'',
|
|
645
|
+
);
|
|
646
|
+
names.push(varName);
|
|
647
|
+
}
|
|
648
|
+
|
|
479
649
|
return {
|
|
480
650
|
block: lines.join('\n').trimEnd(),
|
|
481
651
|
names,
|
package/src/cli/index.ts
CHANGED
|
@@ -359,7 +359,7 @@ Usage:
|
|
|
359
359
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
360
360
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
361
361
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
362
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored)
|
|
362
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
363
363
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
364
364
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
365
365
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
package/src/cli/parse-flags.ts
CHANGED
|
@@ -25,12 +25,32 @@ function isFlag(arg: string): boolean {
|
|
|
25
25
|
return arg.startsWith('-') && arg.length > 1;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Parse `--key value
|
|
28
|
+
/** Parse `--key value`, `--key=value`, `-k value`; a flag with no value becomes `'true'`. */
|
|
29
29
|
export function parseFlags(args: string[]): Record<string, string> {
|
|
30
30
|
const flags: Record<string, string> = {};
|
|
31
31
|
for (let i = 0; i < args.length; i++) {
|
|
32
32
|
const arg = args[i];
|
|
33
33
|
if (!isFlag(arg)) continue;
|
|
34
|
+
|
|
35
|
+
// `--key=value`. Split on the FIRST `=` only: the value is routinely a connection string
|
|
36
|
+
// whose own query carries more (`postgresql://h/db?sslmode=require`), and splitting on
|
|
37
|
+
// every `=` would truncate it to the host.
|
|
38
|
+
//
|
|
39
|
+
// This form used to be dropped ENTIRELY — the whole `--database-url=…` token parsed as a
|
|
40
|
+
// key with no value, so the flag was simply absent. For a venue flag that is not a parse
|
|
41
|
+
// error, it is a silent CHANGE OF TARGET: `db:plan --database-url=postgres://localhost/x`
|
|
42
|
+
// found no url source, fell through to the stage lane, and went to AWS. A consumer lost an
|
|
43
|
+
// afternoon to it against a local database with no AWS at all. On a destructive verb the
|
|
44
|
+
// same slip aims at the deployed stage instead of the local database.
|
|
45
|
+
const eq = arg.indexOf('=');
|
|
46
|
+
if (eq > 1) {
|
|
47
|
+
const rawKey = arg.slice(0, eq);
|
|
48
|
+
const key = rawKey.startsWith('--') ? rawKey.slice(2) : rawKey.slice(1);
|
|
49
|
+
// The empty string is a VALUE, not an absent one — same rule as `--set ''` below.
|
|
50
|
+
flags[key] = arg.slice(eq + 1);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
35
55
|
const next = args[i + 1];
|
|
36
56
|
flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';
|
package/src/plugin.ts
CHANGED
|
@@ -9,17 +9,35 @@
|
|
|
9
9
|
|
|
10
10
|
import type { StorageAdapter } from './storage/index';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* These types MIRROR @everystack/server/plugin. They are not imported from it, and that
|
|
14
|
+
* is deliberate: server imports `@everystack/cli/apply`, `/reconcile`, `/exec` and more,
|
|
15
|
+
* so a type import in the other direction closes a package cycle. Both packages ship
|
|
16
|
+
* TypeScript source, so a type-only import still drags the whole module graph in.
|
|
17
|
+
*
|
|
18
|
+
* The copy going stale is what broke a consumer: server made `publishJob` optional and
|
|
19
|
+
* gave it an options argument, nothing here compared the two declarations, and `Plugin`
|
|
20
|
+
* is contravariant in `ctx` — so cli's `Plugin` silently stopped being assignable to
|
|
21
|
+
* server's and the TS2322 landed in their build instead of ours.
|
|
22
|
+
*
|
|
23
|
+
* **The link is `__tests__/cli/plugin-type-compat.test.ts`**, which imports BOTH and
|
|
24
|
+
* asserts assignability at compile time. The test can cross the boundary because it is
|
|
25
|
+
* not part of either package's published graph. Drift now fails there, in this repo, on
|
|
26
|
+
* the commit that causes it. If you change a shape below, that test is the gate.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Mirrors @everystack/server/plugin PluginContext. `publishJob` is OPTIONAL (a lean app
|
|
30
|
+
* with no @everystack/jobs never sets it) and takes an options argument. */
|
|
13
31
|
interface PluginContext {
|
|
14
32
|
db: any;
|
|
15
33
|
schema: Record<string, any>;
|
|
16
34
|
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
17
35
|
environment: string;
|
|
18
|
-
publishJob
|
|
36
|
+
publishJob?: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
|
|
19
37
|
[key: string]: unknown;
|
|
20
38
|
}
|
|
21
39
|
|
|
22
|
-
/**
|
|
40
|
+
/** Mirrors @everystack/server Route. */
|
|
23
41
|
interface Route {
|
|
24
42
|
path: string;
|
|
25
43
|
method?: string;
|
|
@@ -27,10 +45,10 @@ interface Route {
|
|
|
27
45
|
handler: (req: Request) => Promise<Response>;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
/**
|
|
48
|
+
/** Mirrors @everystack/server/plugin ActionHandler. */
|
|
31
49
|
type ActionHandler = (payload: unknown, ctx: PluginContext) => Promise<unknown>;
|
|
32
50
|
|
|
33
|
-
/**
|
|
51
|
+
/** Mirrors @everystack/server/plugin Plugin. */
|
|
34
52
|
type Plugin = (ctx: PluginContext) => Promise<{
|
|
35
53
|
routes?: Route[];
|
|
36
54
|
actions?: Record<string, ActionHandler>;
|