@everystack/cli 0.4.50 → 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.50",
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>",
@@ -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
@@ -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.
@@ -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,
@@ -16,7 +16,7 @@
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
21
  import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive.js';
22
22
 
@@ -676,7 +676,7 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
676
676
  lines.push(`import { ${result.imports.join(', ')} } from '@everystack/model';`);
677
677
  }
678
678
  const refs = result.modelRefs
679
- .map((table) => ({ varName: knownTables.get(table)!, path: `./${modelFileName(table).replace(/\.ts$/, '')}` }))
679
+ .map((table) => ({ varName: knownTables.get(table)!, path: modelImportPath(table) }))
680
680
  .sort((a, b) => a.path.localeCompare(b.path));
681
681
  for (const ref of refs) lines.push(`import { ${ref.varName} } from '${ref.path}';`);
682
682
  lines.push('', result.block, '');
@@ -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
  }