@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 +1 -1
- package/src/cli/commands/db-check.ts +35 -4
- package/src/cli/commands/db-generate.ts +1 -1
- package/src/cli/commands/db-plan.ts +16 -5
- package/src/cli/commands/db-pull.ts +7 -2
- package/src/cli/db-build.ts +60 -0
- package/src/cli/derived-render.ts +2 -2
- package/src/cli/migration-compile.ts +20 -7
- package/src/cli/model-barrel-lint.ts +115 -0
- package/src/cli/model-render.ts +70 -10
- package/src/cli/schema-introspect.ts +37 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
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.
|
package/src/cli/db-build.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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(
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
}
|
package/src/cli/model-render.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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 '
|
|
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
|
}
|