@everystack/cli 0.4.49 → 0.4.51

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.49",
3
+ "version": "0.4.51",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -64,8 +64,10 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
64
64
  DELETE: 'delete',
65
65
  };
66
66
 
67
- /** The roles the compiler itself emits policies for; anything else is app-specific. */
68
- const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
67
+ /** The roles the compiler itself emits policies for; anything else is app-specific.
68
+ * Exported so the DERIVED renderer applies the identical rule — the views path once had
69
+ * its own idea of which grantees to render, and the two disagreed inside one pull. */
70
+ export const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
69
71
 
70
72
  /**
71
73
  * The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
@@ -75,7 +77,7 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
75
77
  * an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
76
78
  * would GOVERN it — turning a rendering decision into an access decision for every table.
77
79
  */
78
- const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
80
+ export const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
79
81
 
80
82
  /** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
81
83
  function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
@@ -37,7 +37,8 @@ import { compileDrizzleSource } from '../schema-source.js';
37
37
  import { currentGitRef } from '../state-apply.js';
38
38
  import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
39
39
  import { resolveModelsPath } from '../models-path.js';
40
- import { loadModels } from './db-generate.js';
40
+ import { loadModels, loadModules } from './db-generate.js';
41
+ import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '../model-barrel-lint.js';
41
42
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
42
43
  import type { SequenceDescriptor } from '@everystack/model';
43
44
  import type { SourceObject } from '../derived-source.js';
@@ -67,6 +68,10 @@ export interface CheckFinding {
67
68
 
68
69
  export interface StaticCheckInput {
69
70
  modelsPath: string;
71
+ /** Barrel siblings and what each declares — the "on disk but ungoverned" scan. */
72
+ modelFileScans?: ModelFileScan[];
73
+ /** The module's extensions — the bootstrap the from-scratch compose needs. */
74
+ extensions?: string[];
70
75
  /** null = the barrel did not load — the gate's first failure. */
71
76
  models: ModelDescriptor[] | null;
72
77
  modelsError?: string;
@@ -104,6 +109,17 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
104
109
  }
105
110
  findings.push({ level: 'ok', area: 'models', message: `${input.models.length} model(s) loaded from ${input.modelsPath}` });
106
111
 
112
+ // A file on disk the barrel never exported is outside governance while looking declared.
113
+ // FAIL, not warn: it is absent from the fingerprint, so nothing downstream will ever
114
+ // notice, and the only other signal is a model COUNT nobody can compare against.
115
+ const barrelGaps = findUnexportedModels(
116
+ input.modelFileScans ?? [],
117
+ new Set(input.models.map((m) => `${m.schema}.${m.table}`)),
118
+ );
119
+ for (const gap of barrelGaps) {
120
+ findings.push({ level: 'fail', area: 'models', message: gap.message });
121
+ }
122
+
107
123
  // The semantic merge conflict git can't see: two branches declaring the
108
124
  // same table both merge cleanly — the merged state must still compose.
109
125
  const seen = new Map<string, number>();
@@ -144,7 +160,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
144
160
 
145
161
  try {
146
162
  compileDeclaredState(input.models);
147
- const statements = compileMigration(input.models, { sequences: input.sequences });
163
+ const statements = compileMigration(input.models, { sequences: input.sequences, extensions: input.extensions });
148
164
  findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
149
165
  } catch (err: any) {
150
166
  findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
@@ -281,7 +297,7 @@ export interface EphemeralComposeResult {
281
297
  export async function executeEphemeralCompose(
282
298
  adminUrl: string,
283
299
  models: ModelDescriptor[],
284
- opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[] } = {},
300
+ opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
285
301
  ): Promise<EphemeralComposeResult> {
286
302
  const database = `escheck_${process.pid}_${Date.now()}`;
287
303
  await createDatabase(adminUrl, database);
@@ -289,6 +305,7 @@ export async function executeEphemeralCompose(
289
305
  const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
290
306
  declared: opts.declared,
291
307
  sequences: opts.sequences,
308
+ extensions: opts.extensions,
292
309
  actor: opts.actor ?? 'db:check',
293
310
  gitRef: opts.gitRef ?? null,
294
311
  });
@@ -315,6 +332,19 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
315
332
  modelsError = err.message;
316
333
  }
317
334
 
335
+ // Only meaningful once the barrel itself loaded — otherwise every sibling reads as
336
+ // ungoverned and the real error is buried under the noise.
337
+ const modelFileScans = models ? await scanModelDirectory(modelsPath) : [];
338
+
339
+ // The module carries the extensions; `models` alone cannot. Without them the from-scratch
340
+ // compose builds a schema whose column types do not exist.
341
+ let extensions: string[] = [];
342
+ if (models) {
343
+ try {
344
+ extensions = [...new Set((await loadModules(modelsPath)).flatMap((m) => m.extensions ?? []))];
345
+ } catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
346
+ }
347
+
318
348
  const artifactPath = flags['schema-out'] || DEFAULT_SCHEMA_OUT;
319
349
  let artifactSource: string | null = null;
320
350
  try {
@@ -349,7 +379,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
349
379
  } catch { /* no baseline — see above */ }
350
380
 
351
381
  const findings = runStaticChecks({
352
- modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
382
+ modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, sqlDirRetired,
353
383
  sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
354
384
  moduleModels: declaredDb?.models ?? null,
355
385
  baselineSource,
@@ -374,6 +404,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
374
404
  gitRef: currentGitRef(),
375
405
  declared: declaredDb?.objects,
376
406
  sequences: declaredDb?.sequences,
407
+ extensions,
377
408
  });
378
409
  for (const line of ephemeral.report) info(` ${line}`);
379
410
  } catch (err: any) {
@@ -72,7 +72,7 @@ export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]>
72
72
  * COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
73
73
  * older models-only barrel still gets a schema+authz init, just no package functions).
74
74
  */
75
- async function loadModules(modelsPath: string): Promise<Module[]> {
75
+ export async function loadModules(modelsPath: string): Promise<Module[]> {
76
76
  const abs = path.resolve(modelsPath);
77
77
  let mod: any;
78
78
  try {
@@ -221,6 +221,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
221
221
  }
222
222
  }
223
223
 
224
+ // The class rides on the ARTIFACT, not only the terminal — a reviewer reading
225
+ // db.plan.json had the aggregate and no way to reach the statements behind it, so
226
+ // `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
227
+ //
228
+ // Attached HERE, before the plan is serialized AND before planHash is taken, because
229
+ // both must see the same object. It used to be assigned after the write, onto an
230
+ // instance nobody read again: the artifact carried no adoption at all, and the summary
231
+ // counted statements the file could not show. Hashing after attaching also keeps the
232
+ // ref db:plan prints equal to the one db:apply computes for that file — `planHash` is
233
+ // sha256 over the WHOLE plan, so a field added later silently changes the identity.
234
+ //
235
+ // Re-DERIVED from live every mint, never stored across mints: a recorded claim about
236
+ // what the models fail to capture goes stale the moment someone closes the gap.
237
+ const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
238
+ plan.adoption = adoption.statements;
239
+
224
240
  const body = JSON.stringify(plan, null, 2) + '\n';
225
241
  if (out === '-') {
226
242
  console.log(body);
@@ -238,16 +254,11 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
238
254
  // fail to capture goes stale the moment someone closes the gap, and a stale note is worse
239
255
  // than none. `capability` is the count that says "this plan removes something the model
240
256
  // cannot express" — the operator reading this is the last one who can catch it.
241
- const adoption = classifyAdoption(declaredAuthz, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles) });
242
257
  const adoptionTotal = Object.values(adoption.counts).reduce((a, b) => a + b, 0);
243
258
  if (adoptionTotal > 0) {
244
259
  info(`${adoptionTotal} authorization statement(s), by why they exist:`);
245
260
  for (const line of renderAdoptionReport(adoption.counts, adoption.statements)) info(line);
246
261
  }
247
- // The class rides on the ARTIFACT too, not only the terminal. A reviewer reading
248
- // db.plan.json had the aggregate and no way to reach the statements behind it, so
249
- // `unclassified: 5` was unreadable by the only person positioned to catch what it meant.
250
- plan.adoption = adoption.statements;
251
262
 
252
263
  // WHO owns the tables this plan authorizes, and does that owner obey the policies it
253
264
  // is about to write? Re-derived live every mint, never stored — the owner is a fact
@@ -2,7 +2,7 @@
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
4
  * db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
5
- * [--derived-out <file.ts>] [--abilities public-read]
5
+ * [--derived-out <file.ts>] [--abilities live|public-read]
6
6
  *
7
7
  * `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
8
8
  * baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
@@ -108,7 +108,12 @@ export function derivedImportSpecifier(out: string, derivedOut: string): string
108
108
  }
109
109
 
110
110
  export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
111
- const schema = flags.schema || 'public';
111
+ // A LIST: `--schema public,auth,metrics`. One value still works. Without this there was
112
+ // no way to adopt a multi-schema database — `--out <dir>` regenerates the barrel, so a
113
+ // second pull replaced the first schema's models rather than joining them.
114
+ const schemas = (flags.schema || 'public').split(',').map((s) => s.trim()).filter(Boolean);
115
+ if (schemas.length === 0) fail('--schema needs at least one schema name.');
116
+ const schema = schemas.length === 1 ? schemas[0] : schemas;
112
117
 
113
118
  // --derived-out: the brownfield splice, first-class. A consumer with an existing
114
119
  // hand-maintained barrel wants the derived layer as its own file — not codemodded
@@ -167,7 +172,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
167
172
  runner = lambdaQueryRunner(config.region, opsFunction(config));
168
173
  session = lambdaSessionRunner(config.region, opsFunction(config));
169
174
  }
170
- note(`Introspecting live database (schema: ${schema})...`);
175
+ note(`Introspecting live database (schema: ${schemas.join(", ")})...`);
171
176
  current = await introspectSchema(session);
172
177
  // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
173
178
  // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
@@ -358,7 +363,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
358
363
  const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
359
364
  if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
360
365
  if (abilities === 'commented') {
361
- note(`Each model scaffolds its authz decision as comments author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
366
+ // `live` FIRST, and named. It is the mode brownfield adoption needs derive the authz
367
+ // from grants and policies the database already has — and it was advertised nowhere:
368
+ // not in the usage line, not in --help, not here. The only place an operator met the
369
+ // word was the error text for an unknown preset, so the discovery path for the right
370
+ // flag was to guess a wrong one. A consumer and their agent both walked past it in one
371
+ // session, and it cost a misdiagnosis plus a needless overwrite of 30 model files.
372
+ //
373
+ // `public-read` is deliberately no longer the headline: on an existing database it
374
+ // stamps can('read') on EVERY table, which on a real schema means public read of
375
+ // credit_cards, transactions, emails and users.
376
+ note(
377
+ `Each model scaffolds its authz decision as comments — db:check fails until every model declares. `
378
+ + `For an EXISTING database, re-pull with --abilities live to derive them from the grants and policies `
379
+ + `already there. --abilities public-read stamps public-read/admin-write on every table — greenfield only.`,
380
+ );
362
381
  } else {
363
382
  note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
364
383
  }
@@ -17,6 +17,7 @@ import type { SequenceDescriptor } from '@everystack/model';
17
17
  import { compileDeclaredState } from './declared-diff.js';
18
18
  import { createUrlRunner } from './db-source.js';
19
19
  import { executeSync, buildSyncReport } from './commands/db-sync.js';
20
+ import { executeReconcile } from './commands/db-reconcile.js';
20
21
 
21
22
  const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
22
23
 
@@ -93,6 +94,10 @@ export interface BuildOptions {
93
94
  declared?: SourceObject[];
94
95
  /** Standalone sequences (state — created before tables, in the fingerprint bar). */
95
96
  sequences?: SequenceDescriptor[];
97
+ /** Extensions the declared state needs. Applied BEFORE anything else — a column typed
98
+ * `hstore` cannot be created until the type exists, and the sync path is a DIFF, which
99
+ * has no bootstrap phase of its own. `IF NOT EXISTS`, so re-running is free. */
100
+ extensions?: string[];
96
101
  actor?: string | null;
97
102
  gitRef?: string | null;
98
103
  }
@@ -109,7 +114,62 @@ export async function buildIntoDatabase(
109
114
  ): Promise<BuildResult> {
110
115
  const { runner, session, end } = await createUrlRunner(url);
111
116
  try {
117
+ // Bootstrap FIRST: roles and tables both come after the types they use exist.
118
+ for (const ext of [...new Set(options.extensions ?? [])].sort()) {
119
+ await runner(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
120
+ }
121
+ // Schemas, for the same reason. This path is a DIFF (executeSync), and a diff has no
122
+ // bootstrap phase: `compileMigration` emits CREATE SCHEMA for every non-public schema a
123
+ // model lives in, but nothing on the sync path did — so a multi-schema declared state
124
+ // failed with `schema "auth" does not exist` before a single table was created. Taken
125
+ // from the MODELS and from the declared derived objects, because a schema can hold only
126
+ // functions (an `auth` of nothing but SECURITY DEFINER functions is a real shape).
127
+ const modelSchemas = models.map((m) => m.schema ?? 'public');
128
+ const derivedSchemas = (options.declared ?? []).map((o) => o.schema);
129
+ for (const schema of [...new Set([...modelSchemas, ...derivedSchemas])].filter((s) => s && s !== 'public').sort()) {
130
+ await runner(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
131
+ }
112
132
  const createdRoles = await ensureContractRoles(runner, models);
133
+
134
+ // FUNCTIONS BEFORE STATE. An RLS policy's predicate is resolved when the policy is
135
+ // created, so `USING (user_id = auth.user_id())` cannot be created before
136
+ // `auth.user_id()` exists — and the sync path applies state (tables, RLS, policies)
137
+ // before the derived layer. On a real brownfield schema that is not an edge case: 18 of
138
+ // one consumer's policies call an `auth.*` function, so the from-scratch build failed
139
+ // with `function auth.user_id() does not exist` before any of them could be created.
140
+ //
141
+ // Only FUNCTIONS move: views and triggers depend on TABLES, so the layers genuinely
142
+ // interleave and "derived before state" would just fail the other way round.
143
+ //
144
+ // `check_function_bodies = off` for this window — the pg_dump restore idiom. A
145
+ // SQL-language function whose body reads a table that does not exist yet is a forward
146
+ // reference, not an error; plpgsql bodies are never validated at creation, so only the
147
+ // SQL-language ones need it. MEASURED, not assumed: dropping this SET puts the reference
148
+ // brownfield schema straight back to failing, so the escape is load-bearing.
149
+ //
150
+ // It is a BARE set, not SET LOCAL, and that is deliberate: reconcile opens its own
151
+ // transaction, so this pass has none to scope to. It is safe under the rule the repo's
152
+ // own guard documents — `createUrlRunner` gives this command a dedicated max:1
153
+ // connection it owns for the run and closes in a finally, never a pooled or shared one.
154
+ // RESET in a finally so the window closes even on failure.
155
+ //
156
+ // Provenance is recorded by this pass, so executeSync's own reconcile below sees the
157
+ // functions already managed and unchanged, and plans nothing for them.
158
+ const declaredFunctions = (options.declared ?? []).filter((o) => o.kind === 'function');
159
+ if (declaredFunctions.length > 0) {
160
+ await runner('SET check_function_bodies = off');
161
+ try {
162
+ const pre = await executeReconcile(runner, session, {
163
+ declared: declaredFunctions,
164
+ apply: true,
165
+ actor: options.actor ?? 'db-build',
166
+ gitRef: options.gitRef ?? null,
167
+ });
168
+ if (pre.refusal) throw new Error(`could not create declared functions first: ${pre.refusal}`);
169
+ } finally {
170
+ await runner('RESET check_function_bodies');
171
+ }
172
+ }
113
173
  const run = await executeSync(runner, session, models, {
114
174
  declared: options.declared,
115
175
  sequences: options.sequences,
@@ -32,6 +32,7 @@ import type {
32
32
  FunctionDescriptor, DependsOnRef,
33
33
  } from '@everystack/model';
34
34
  import { parseQualified } from './derived-source.js';
35
+ import { GOVERNED_VOCABULARY } from './authz-derive.js';
35
36
 
36
37
  export interface DerivedAuthzGap {
37
38
  /** `schema.name` of the descriptor that fails the gate. */
@@ -60,6 +61,10 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
60
61
  * as zero-reach, which bricked every invoker view over a private table.
61
62
  */
62
63
  export function tableReaches(m: ModelDescriptor, role: string): boolean {
64
+ // A recorded grant is reach too. PUBLIC SELECT is held by every role; a named grantee
65
+ // reaches itself. See grantsPublic — this is the half the gate kept forgetting.
66
+ if (grantsPublic(m.privileges, 'SELECT')) return true;
67
+ if (privilegeRoles(m.privileges, 'SELECT').includes(role)) return true;
63
68
  for (const a of m.abilities) {
64
69
  if (isColumnAbility(a)) {
65
70
  // Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
@@ -79,6 +84,30 @@ export function tableReaches(m: ModelDescriptor, role: string): boolean {
79
84
  return false;
80
85
  }
81
86
 
87
+ /**
88
+ * REACH comes from abilities AND from recorded privileges. Forgetting the second half is a
89
+ * bug this repo has now made three times — the 63-byte ACL truncation, the SECDEF caller
90
+ * gate, and this one — always the same shape: a fact recorded as `privileges` falls out of
91
+ * a downstream read and lands in neither set.
92
+ *
93
+ * `PUBLIC` is the case that matters. `GRANT … TO PUBLIC` is held by EVERY role, so a
94
+ * dependency carrying one is reachable by all of them, and a live PUBLIC grant is exactly
95
+ * what `db:pull` RECORDS rather than inventing an audience for. Reading abilities alone
96
+ * made a faithful pull fail to COMPILE, with no hand-edit available to fix it.
97
+ */
98
+ function grantsPublic(privileges: Record<string, string[]> | undefined, privilege: string): boolean {
99
+ return Object.entries(privileges ?? {}).some(
100
+ ([grantee, privs]) => grantee.toUpperCase() === 'PUBLIC' && privs.some((p) => p.toUpperCase() === privilege),
101
+ );
102
+ }
103
+
104
+ /** Grantees a recorded privilege names directly — reach for that role, nobody else. */
105
+ function privilegeRoles(privileges: Record<string, string[]> | undefined, privilege: string): string[] {
106
+ return Object.entries(privileges ?? {})
107
+ .filter(([, privs]) => privs.some((p) => p.toUpperCase() === privilege))
108
+ .map(([grantee]) => grantee);
109
+ }
110
+
82
111
  /** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
83
112
  function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
84
113
  const roles = new Set<string>();
@@ -89,12 +118,15 @@ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<stri
89
118
  roles.add('authenticated');
90
119
  }
91
120
  }
121
+ for (const r of privilegeRoles(d.privileges, 'SELECT')) roles.add(r);
92
122
  return roles;
93
123
  }
94
124
 
95
125
  function functionRoles(fn: FunctionDescriptor): Set<string> {
96
126
  // define-time already rejected bare can('execute') — every ability carries a role.
97
- return new Set(fn.abilities.map((a) => a.condition.role!));
127
+ const roles = new Set(fn.abilities.map((a) => a.condition.role!));
128
+ for (const r of privilegeRoles(fn.privileges, 'EXECUTE')) roles.add(r);
129
+ return roles;
98
130
  }
99
131
 
100
132
  // ---------------------------------------------------------------------------
@@ -262,7 +294,7 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
262
294
  }
263
295
  switch (ref.kind) {
264
296
  case 'view': {
265
- if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
297
+ if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
266
298
  // An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
267
299
  if (ref.securityInvoker) {
268
300
  const deeper = findUnreachable(role, ref.dependsOn, seen);
@@ -272,11 +304,11 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
272
304
  }
273
305
  case 'materialized view':
274
306
  // A matview is its own snapshot: SELECT on the matview is the whole requirement.
275
- if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
307
+ if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
276
308
  break;
277
309
  case 'function':
278
310
  // The body calls it with the caller's rights — EXECUTE is part of reach.
279
- if (!functionRoles(ref).has(role)) return identityOf(ref);
311
+ if (!functionRoles(ref).has(role) && !grantsPublic(ref.privileges, 'EXECUTE')) return identityOf(ref);
280
312
  break;
281
313
  case 'sql':
282
314
  break; // opaque — never a false positive
@@ -296,7 +328,12 @@ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[
296
328
  for (const d of derived) {
297
329
  if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
298
330
  const identity = identityOf(d);
299
- for (const role of [...relationRoles(d)].sort()) {
331
+ // Only roles the declared world can SATISFY. A grantee outside the compiler's
332
+ // vocabulary is recorded fact on the view AND on the table (the tables path never
333
+ // renders one as an ability), so table-side reach for it is not expressible — checking
334
+ // it is a demand no model can ever meet. Their reach is adopted via authz-baseline,
335
+ // outside this gate. PUBLIC is governed and stays checked.
336
+ for (const role of [...relationRoles(d)].filter((r) => GOVERNED_VOCABULARY.has(r)).sort()) {
300
337
  const unreachable = findUnreachable(role, d.dependsOn, new Set());
301
338
  if (unreachable) {
302
339
  gaps.push({
@@ -16,8 +16,9 @@
16
16
  import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
17
17
  import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introspect.js';
18
18
  import { parseIndexDefinition } from './schema-introspect.js';
19
- import { modelFileName, renderFieldLines } from './model-render.js';
19
+ import { modelImportPath, 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. */
@@ -100,12 +101,28 @@ function tsString(s: string): string {
100
101
  */
101
102
  function splitRelationGrants(
102
103
  grants: Record<string, string[]>,
103
- ): { abilities: string[]; privileges: Record<string, string[]> } {
104
+ ): { abilities: string[]; privileges: Record<string, string[]>; foreign: string[] } {
104
105
  const readRoles = new Set<string>();
105
106
  const privileges: Record<string, string[]> = {};
107
+ const foreign: string[] = [];
106
108
  for (const [grantee, privs] of Object.entries(grants)) {
107
109
  const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
108
- if (selectOnly && grantee.toUpperCase() !== 'PUBLIC') readRoles.add(grantee);
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);
109
126
  else privileges[grantee] = [...privs].sort();
110
127
  }
111
128
  const abilities: string[] = [];
@@ -115,7 +132,7 @@ function splitRelationGrants(
115
132
  readRoles.delete('authenticated');
116
133
  }
117
134
  for (const role of [...readRoles].sort()) abilities.push(`can('read', { role: ${tsString(role)} })`);
118
- return { abilities, privileges };
135
+ return { abilities, privileges, foreign: foreign.sort() };
119
136
  }
120
137
 
121
138
  /**
@@ -363,7 +380,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
363
380
  // the abilities we can express would leave the write grants undeclared, so the next
364
381
  // generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
365
382
  // Declared == live means nothing is planned at all.
366
- const { abilities, privileges } = splitRelationGrants(o.grants ?? {});
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
+ }
367
392
  const writeGrants = Object.values(privileges).some((ps) =>
368
393
  ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
369
394
  );
@@ -651,7 +676,7 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
651
676
  lines.push(`import { ${result.imports.join(', ')} } from '@everystack/model';`);
652
677
  }
653
678
  const refs = result.modelRefs
654
- .map((table) => ({ varName: knownTables.get(table)!, path: `./${modelFileName(table).replace(/\.ts$/, '')}` }))
679
+ .map((table) => ({ varName: knownTables.get(table)!, path: modelImportPath(table) }))
655
680
  .sort((a, b) => a.path.localeCompare(b.path));
656
681
  for (const ref of refs) lines.push(`import { ${ref.varName} } from '${ref.path}';`);
657
682
  lines.push('', result.block, '');
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).
@@ -38,10 +38,21 @@ function emptyTable(table: string): TableContract {
38
38
  * schema). Tables, then foreign keys, then authz — the order a fresh database must
39
39
  * apply them in.
40
40
  */
41
- export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions & { sequences?: SequenceDescriptor[] } = {}): string[] {
41
+ export function compileMigration(
42
+ models: ModelDescriptor[],
43
+ opts: CompileTableOptions & { sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
44
+ ): string[] {
42
45
  const sql: string[] = [];
43
46
  const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
44
47
 
48
+ // 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
49
+ // compileModuleMigration has always emitted these; compileMigration had no way to,
50
+ // so db:check's from-scratch ring compiled a state whose column types did not exist
51
+ // and failed with `type "hstore" does not exist` on an otherwise clean schema.
52
+ // Deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) stays valid.
53
+ const extensions = [...new Set(opts.extensions ?? [])].sort();
54
+ sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
55
+
45
56
  // 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
46
57
  // before any table is created in it. public is the implicit default, never emitted.
47
58
  const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
@@ -137,12 +148,14 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
137
148
  export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
138
149
  const sql: string[] = [];
139
150
 
140
- // 1. Extensions deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) is valid.
141
- const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
142
- sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
143
-
144
- // 2. Schema + authz for every modeled table (plus the modules' standalone sequences).
145
- sql.push(...compileMigration(modules.flatMap((m) => m.models), { ...opts, sequences: modules.flatMap((m) => m.sequences) }));
151
+ // 1+2. Extensions (the bootstrap) then schema + authz for every modeled table, plus the
152
+ // modules' standalone sequences. compileMigration owns the extension emission now, so
153
+ // the two entry points cannot disagree about the order or the quoting.
154
+ sql.push(...compileMigration(modules.flatMap((m) => m.models), {
155
+ ...opts,
156
+ sequences: modules.flatMap((m) => m.sequences),
157
+ extensions: modules.flatMap((m) => m.extensions),
158
+ }));
146
159
 
147
160
  // 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
148
161
  // output is a self-contained multi-statement block applied as one unit.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * model-barrel-lint — the file that is on disk and outside governance.
3
+ *
4
+ * Every verb reads the BARREL: `db:check`, `db:plan` and `db:fingerprint` all call
5
+ * `loadModels(modelsPath)` and see exactly what it exports. So a model file sitting in
6
+ * `db/models/` that `index.ts` never imports does not exist as far as any of them is
7
+ * concerned — it is absent from the fingerprint, absent from the plan, and absent from the
8
+ * check — while looking entirely declared to a human reading the directory.
9
+ *
10
+ * That is the failure the declared-state model exists to prevent: a table quietly outside
11
+ * governance, with a file that says otherwise. It is also silent by construction, because
12
+ * the only signal is a COUNT ("30 model(s)") that matches nothing the reader can compare it
13
+ * to. A consumer lost an hour to it and drew a false stage-drift conclusion from the
14
+ * resulting one-statement plan.
15
+ *
16
+ * The scan is deliberately shallow: the barrel's OWN directory, non-recursive. A models
17
+ * directory is a flat directory of models by convention, and walking deeper would start
18
+ * flagging fixtures and generated output.
19
+ */
20
+
21
+ import fs from 'node:fs/promises';
22
+ import path from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+ import type { ModelDescriptor } from '@everystack/model';
25
+
26
+ /** One file's declared tables, as read off its own exports. */
27
+ export interface ModelFileScan {
28
+ /** Basename, relative to the barrel's directory — what the operator has to go open. */
29
+ file: string;
30
+ /** Qualified tables (`schema.table`) the file declares, in any order. */
31
+ tables: string[];
32
+ }
33
+
34
+ export interface UnexportedModelGap {
35
+ file: string;
36
+ /** The declared tables no verb can see, sorted. */
37
+ tables: string[];
38
+ message: string;
39
+ }
40
+
41
+ /**
42
+ * Files declaring a table the barrel never exported. Pure — the caller does the reading, so
43
+ * the rule is testable without a filesystem.
44
+ *
45
+ * Findings are per FILE, because the file is the unit that gets fixed (add it to the
46
+ * barrel, or delete it). Everything is sorted: import order is not a contract, and a lint
47
+ * whose output reorders between runs is one nobody can diff.
48
+ */
49
+ export function findUnexportedModels(
50
+ scans: readonly ModelFileScan[],
51
+ loadedTables: ReadonlySet<string>,
52
+ ): UnexportedModelGap[] {
53
+ const gaps: UnexportedModelGap[] = [];
54
+ for (const scan of scans) {
55
+ const missing = scan.tables.filter((t) => !loadedTables.has(t)).sort();
56
+ if (missing.length === 0) continue;
57
+ gaps.push({
58
+ file: scan.file,
59
+ tables: missing,
60
+ message:
61
+ `${scan.file} declares ${missing.join(', ')} but the barrel does not export it — so no verb can see it. ` +
62
+ `db:check, db:plan and db:fingerprint all read the barrel, which means the table is outside governance ` +
63
+ `while the file makes it look declared. Export it from the barrel, or delete the file.`,
64
+ });
65
+ }
66
+ return gaps.sort((a, b) => a.file.localeCompare(b.file));
67
+ }
68
+
69
+ /** A qualified table identity, matching the form the loaded models are keyed by. */
70
+ export function modelIdentity(m: ModelDescriptor): string {
71
+ return `${m.schema}.${m.table}`;
72
+ }
73
+
74
+ /**
75
+ * Read the barrel's sibling files and report what each one declares.
76
+ *
77
+ * Import failures are SKIPPED, not raised: a sibling that does not compile is a different
78
+ * problem with its own error path, and a lint that turns an unrelated broken file into a
79
+ * governance finding would send the operator to the wrong place. A file exporting no model
80
+ * simply scans as zero tables.
81
+ */
82
+ export async function scanModelDirectory(modelsPath: string): Promise<ModelFileScan[]> {
83
+ const abs = path.resolve(modelsPath);
84
+ const dir = path.dirname(abs);
85
+ const barrel = path.basename(abs);
86
+
87
+ let entries: string[];
88
+ try {
89
+ entries = await fs.readdir(dir);
90
+ } catch {
91
+ return []; // Not a directory-shaped barrel (a single-module app); nothing to scan.
92
+ }
93
+
94
+ const scans: ModelFileScan[] = [];
95
+ for (const entry of entries.sort()) {
96
+ if (entry === barrel) continue;
97
+ if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
98
+ let mod: Record<string, unknown>;
99
+ try {
100
+ mod = (await import(pathToFileURL(path.join(dir, entry)).href)) as Record<string, unknown>;
101
+ } catch {
102
+ continue;
103
+ }
104
+ const tables = Object.values(mod)
105
+ .filter((v): v is ModelDescriptor => (
106
+ typeof v === 'object' && v !== null
107
+ && typeof (v as ModelDescriptor).table === 'string'
108
+ && typeof (v as ModelDescriptor).schema === 'string'
109
+ && Array.isArray((v as ModelDescriptor).abilities)
110
+ ))
111
+ .map(modelIdentity);
112
+ scans.push({ file: entry, tables: [...new Set(tables)] });
113
+ }
114
+ return scans;
115
+ }
@@ -61,7 +61,8 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
61
61
 
62
62
  export interface RenderOptions {
63
63
  /** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
64
- schema?: string;
64
+ /** One schema, or several — a database is not always one schema. Default `'public'`. */
65
+ schema?: string | string[];
65
66
  /**
66
67
  * The read-model scaffold. Default `'commented'`: every model carries the authz decision
67
68
  * as a commented stanza (same guidance as the db:check gate — the model fails the gate
@@ -241,8 +242,25 @@ const INFRASTRUCTURE_TABLES = new Set([
241
242
  * database can carry `public.__drizzle_migrations` — the journal is tooling state,
242
243
  * never an app model. Exported so the command shell counts the same set it writes.
243
244
  */
244
- export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableSchema[] {
245
- return snapshot.tables.filter((t) => t.table.startsWith(`${schema}.`) && !INFRASTRUCTURE_TABLES.has(bareName(t.table)));
245
+ export function pullableTables(snapshot: SchemaSnapshot, schema: string | string[]): TableSchema[] {
246
+ // A LIST, because a database is not one schema. `--schema` took a single value and
247
+ // `--out <dir>` regenerates the barrel, so pulling a second schema overwrote the first —
248
+ // there was no way to land a multi-schema database in one declared state at all.
249
+ //
250
+ // Matched on the exact prefix, never `startsWith(schema)` alone: `pub` must not match
251
+ // `public.users`.
252
+ const schemas = new Set(Array.isArray(schema) ? schema : [schema]);
253
+ return snapshot.tables.filter((t) => {
254
+ const dot = t.table.indexOf('.');
255
+ const owner = dot === -1 ? 'public' : t.table.slice(0, dot);
256
+ return schemas.has(owner) && !INFRASTRUCTURE_TABLES.has(bareName(t.table));
257
+ });
258
+ }
259
+
260
+ /** The schema a qualified (or bare) table lives in — bare means public. */
261
+ function schemaOf(table: string): string {
262
+ const dot = table.indexOf('.');
263
+ return dot === -1 ? 'public' : table.slice(0, dot);
246
264
  }
247
265
 
248
266
  /**
@@ -258,7 +276,14 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string): TableS
258
276
  export function modelVarName(table: string): string {
259
277
  const bare = bareName(table);
260
278
  const singular = bare.endsWith('s') ? bare.slice(0, -1) : bare;
261
- return singular.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
279
+ const pascal = (word: string): string =>
280
+ word.split('_').filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
281
+ // Non-public schemas are QUALIFIED, so `public.users` and `auth.users` can live in one
282
+ // barrel. Public stays bare, so an existing single-schema barrel does not churn and
283
+ // nothing renames when a second schema arrives. Same rule the derived renderer already
284
+ // uses for its own symbols.
285
+ const owner = schemaOf(table);
286
+ return owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular);
262
287
  }
263
288
 
264
289
  /**
@@ -511,7 +536,15 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
511
536
  const writtenBy = writtenByStanza(table, liveAuthz);
512
537
  const softDelete = softDeleteStanza(table, stanza.publicRead);
513
538
 
514
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
539
+ // A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
540
+ // qualification cannot ride in the first argument (that would make the table literally
541
+ // named `metrics.impressions`). Without it a multi-schema pull rendered
542
+ // `defineModel('impressions')`, which declares `public.impressions`, and the matview that
543
+ // selects FROM metrics.impressions then failed to build. Same idiom the derived renderer
544
+ // already uses for defineMaterializedTable.
545
+ const owner = schemaOf(table.table);
546
+ const schemaProp = owner === 'public' ? '' : ` schema: '${owner}',\n`;
547
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
515
548
  }
516
549
 
517
550
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -548,6 +581,7 @@ function moduleFooter(
548
581
  derived?: DerivedRenderResult,
549
582
  multiline = false,
550
583
  external?: ExternalDerived,
584
+ extensions?: string[],
551
585
  ): string {
552
586
  // A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
553
587
  // render (topo-ordered with the objects around it) but its name belongs in `models`.
@@ -570,6 +604,22 @@ function moduleFooter(
570
604
  if (!external) parts.push(`export const derived = [${derived!.names.join(', ')}];`);
571
605
  keys.push('derived');
572
606
  }
607
+ // Extensions are the BOOTSTRAP half of a declared state: `field.pgType('hstore')` compiles
608
+ // to a column whose type does not exist unless something ran CREATE EXTENSION first.
609
+ // `defineModule` has always accepted the key and migration-compile has always emitted
610
+ // `CREATE EXTENSION IF NOT EXISTS` from it — the renderer was the only missing link, and a
611
+ // consumer found it when db:check's from-scratch ring failed with `type "hstore" does not
612
+ // exist` on a state that was otherwise clean.
613
+ //
614
+ // ALL installed extensions, not only those reachable from a declared column type: an
615
+ // adopter's `pg_stat_statements` and `unaccent` reach no column, and a from-scratch
616
+ // database without them is not their database.
617
+ if (extensions?.length) {
618
+ // Sorted HERE, not just at the introspection: the renderer owns byte-stability, and a
619
+ // caller passing an unsorted list must not produce a diff against an identical database.
620
+ parts.push(`export const extensions = [${[...extensions].sort().map((e) => `'${e}'`).join(', ')}];`);
621
+ keys.push('extensions');
622
+ }
573
623
  parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
574
624
  parts.push(`export const modules = [appModule];`);
575
625
  return parts.join('\n\n');
@@ -611,7 +661,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
611
661
 
612
662
  // Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
613
663
  // so a fresh brownfield project lands on the same shape the framework composes.
614
- const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
664
+ const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions);
615
665
 
616
666
  const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
617
667
 
@@ -626,8 +676,18 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
626
676
  * NAMING CONTRACT — changing this rule is a BREAKING CHANGE: index.ts and cross-file
627
677
  * FK imports reference these paths. Pinned by naming-contract.test.ts.
628
678
  */
679
+ /** The import specifier a barrel (or a sibling file) uses for a model — the FILENAME rule
680
+ * minus the extension. Derived from modelFileName so the two can never disagree; they did,
681
+ * and a multi-schema pull emitted `import … from './refresh-tokens'` next to a file called
682
+ * `auth-refresh-tokens.ts`. */
683
+ export function modelImportPath(table: string): string {
684
+ return `./${modelFileName(table).replace(/\.ts$/, '')}`;
685
+ }
686
+
629
687
  export function modelFileName(table: string): string {
630
- return `${bareName(table).replace(/_/g, '-')}.ts`;
688
+ const owner = schemaOf(table);
689
+ const bare = bareName(table).replace(/_/g, '-');
690
+ return owner === 'public' ? `${bare}.ts` : `${owner.replace(/_/g, '-')}-${bare}.ts`;
631
691
  }
632
692
 
633
693
  /** The bare tables (other than itself) a table's rendered FKs reference within the pulled set. */
@@ -664,7 +724,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
664
724
  const files: RenderedModelFile[] = tables.map((t) => {
665
725
  const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
666
726
  const crossImports = referencedTables(t, known).map(
667
- (target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
727
+ (target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
668
728
  );
669
729
  const header = [importHeader(block), ...crossImports].join('\n');
670
730
  return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
@@ -683,13 +743,13 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
683
743
  // The --derived-out layer, imported rather than re-declared — the barrel must WIRE it
684
744
  // or db:plan silently compares against models only.
685
745
  ...(extNames.length ? [`import { ${extNames.join(', ')} } from '${ext!.specifier}';`] : []),
686
- ...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
746
+ ...names.map((n, i) => `import { ${n} } from '${modelImportPath(tables[i].table)}';`),
687
747
  ].join('\n'),
688
748
  `export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
689
749
  // Re-exported so the barrel remains the one place that describes the database.
690
750
  ...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
691
751
  ...(opts.derived?.block ? [opts.derived.block] : []),
692
- moduleFooter(names, opts.derived, true, ext),
752
+ moduleFooter(names, opts.derived, true, ext, snapshot.extensions),
693
753
  ].join('\n\n');
694
754
  files.push({ file: 'index.ts', source: index + '\n' });
695
755
 
@@ -105,6 +105,8 @@ export interface SchemaSnapshot {
105
105
  enums?: EnumType[];
106
106
  /** Standalone sequences (serial-owned ones excluded). Absent/empty when none. */
107
107
  sequences?: SequenceSchema[];
108
+ /** Installed extensions, `plpgsql` excluded. Absent when the read predates this. */
109
+ extensions?: string[];
108
110
  }
109
111
 
110
112
  // ---------------------------------------------------------------------------
@@ -216,6 +218,27 @@ ORDER BY n.nspname, t.typname;
216
218
  * has an 'a'/'i' pg_depend link to its column and stays represented BY that column).
217
219
  * These hold state, so they are base-schema: migrated, diffed, fingerprinted.
218
220
  */
221
+ /**
222
+ * Installed extensions — the bootstrap a declared state needs before anything else.
223
+ *
224
+ * Database-wide, not per-schema: `pg_extension` has no schema filter worth applying, and an
225
+ * extension installed into `public` is just as required by a model in `auth`. So this read
226
+ * ignores `--schema` deliberately.
227
+ *
228
+ * `plpgsql` is excluded because PostgreSQL installs it into every database by default —
229
+ * emitting `CREATE EXTENSION IF NOT EXISTS plpgsql` is noise, not bootstrap.
230
+ *
231
+ * Without this, a declared state that renders `field.pgType('hstore')` compiles to a CREATE
232
+ * TABLE whose type does not exist, and `db:check`'s from-scratch ring fails with
233
+ * `type "hstore" does not exist` — the state describes a database it cannot build.
234
+ */
235
+ export const EXTENSIONS_SQL = `
236
+ SELECT extname AS name
237
+ FROM pg_extension
238
+ WHERE extname <> 'plpgsql'
239
+ ORDER BY extname;
240
+ `.trim();
241
+
219
242
  export const SEQUENCES_SQL = `
220
243
  SELECT
221
244
  n.nspname AS schema,
@@ -540,6 +563,7 @@ export interface SchemaRows {
540
563
  enums?: EnumRow[];
541
564
  indexes?: IndexRow[];
542
565
  sequences?: SequenceRow[];
566
+ extensions?: ExtensionRow[];
543
567
  }
544
568
 
545
569
  /**
@@ -618,10 +642,18 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
618
642
  .map(sequenceRowToDescriptor)
619
643
  .sort((a, b) => a.name.localeCompare(b.name));
620
644
 
645
+ // ABSENT vs EMPTY matters: a caller that did not run the extensions query leaves the key
646
+ // off entirely, and the renderer must not read that as "this database has none" and emit
647
+ // an empty list that later looks authoritative.
648
+ const extensions = rows.extensions === undefined
649
+ ? undefined
650
+ : rows.extensions.map((r) => r.name).sort();
651
+
621
652
  return {
622
653
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
623
654
  enums,
624
655
  ...(sequences.length ? { sequences } : {}),
656
+ ...(extensions !== undefined ? { extensions } : {}),
625
657
  };
626
658
  }
627
659
 
@@ -631,13 +663,15 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
631
663
  * (the ops Lambda `db:query` in production, a fake in tests) and folds the rows with
632
664
  * `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
633
665
  */
666
+ export interface ExtensionRow { name: string }
667
+
634
668
  export async function introspectSchema(session: SessionRunner): Promise<SchemaSnapshot> {
635
669
  // ONE session, so all five queries see one database at one moment, under one pinned
636
670
  // search_path. Column defaults, CHECK constraints and index expressions all deparse
637
671
  // relative to that path — spread across separate connections these five rows can render
638
672
  // the same schema two ways, and a fingerprint minted from the mix describes nothing.
639
- const [columns, constraints, enums, indexes, sequences] = await session(
640
- [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL],
673
+ const [columns, constraints, enums, indexes, sequences, extensions] = await session(
674
+ [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL],
641
675
  INTROSPECTION_SESSION,
642
676
  );
643
677
  return assembleSchema({
@@ -646,5 +680,6 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
646
680
  enums: enums as EnumRow[],
647
681
  indexes: indexes as IndexRow[],
648
682
  sequences: sequences as SequenceRow[],
683
+ extensions: extensions as ExtensionRow[],
649
684
  });
650
685
  }
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>;