@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.
@@ -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
- /** The relation grant shapes `abilities` can say. Null = inexpressible (caller FIXMEs). */
85
- function relationAbilities(grants: Record<string, string[]>): string[] | null {
86
- const roles = Object.keys(grants);
87
- for (const role of roles) {
88
- if (role.toUpperCase() === 'PUBLIC') return null;
89
- if (grants[role].some((p) => p !== 'SELECT')) return null;
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 set = new Set(roles);
92
- const out: string[] = [];
93
- if (set.has('anon') && set.has('authenticated')) {
94
- out.push(`can('read')`);
95
- set.delete('anon');
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 [...set].sort()) out.push(`can('read', { role: ${tsString(role)} })`);
99
- return out;
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
- for (const t of catalog.objects.filter((o) => o.kind === 'trigger')) {
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
- const abilities = relationAbilities(o.grants ?? {});
306
- if (abilities === null) {
307
- const grantText = Object.entries(o.grants ?? {}).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
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 writeGrants = Object.values(o.grants ?? {}).some((ps) =>
313
- ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
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
- ` ${abilities.length ? `abilities: [${abilities.join(', ')}],` : 'private: true,'}`,
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
- if (abilities.length === 0) props.push('private: true,');
382
- else {
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
- ...(publicExec
469
- ? [`// FIXME: ${o.identity} live grants EXECUTE to PUBLIC; descriptors ALWAYS revoke PUBLIC. Declare the intended callers (adopting this descriptor applies the revoke).`]
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); --abilities public-read stamps the common stanza (public read, admin write) uncommented — 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
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).
@@ -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` / `-k value` pairs; a flag with no value becomes `'true'`. */
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
- /** Minimal plugin context (compatible with @everystack/server/plugin PluginContext) */
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: (type: string, payload: unknown) => Promise<string>;
36
+ publishJob?: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
19
37
  [key: string]: unknown;
20
38
  }
21
39
 
22
- /** Minimal route type (compatible with @everystack/server Route) */
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
- /** Action handler type (compatible with @everystack/server/plugin ActionHandler) */
48
+ /** Mirrors @everystack/server/plugin ActionHandler. */
31
49
  type ActionHandler = (payload: unknown, ctx: PluginContext) => Promise<unknown>;
32
50
 
33
- /** Plugin factory function */
51
+ /** Mirrors @everystack/server/plugin Plugin. */
34
52
  type Plugin = (ctx: PluginContext) => Promise<{
35
53
  routes?: Route[];
36
54
  actions?: Record<string, ActionHandler>;