@everystack/cli 0.3.28 → 0.4.0
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/README.md +1 -1
- package/package.json +6 -2
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +97 -34
- package/src/cli/commands/db-fingerprint.ts +9 -4
- package/src/cli/commands/db-generate.ts +73 -22
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +39 -7
- package/src/cli/commands/db-reconcile.ts +219 -66
- package/src/cli/commands/db-sync.ts +58 -21
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +83 -7
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +194 -12
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/discover.ts +16 -0
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +26 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +4 -1
package/src/cli/model-render.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
|
|
19
|
+
import type { DerivedRenderResult } from './derived-render.js';
|
|
19
20
|
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -59,6 +60,47 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
|
|
|
59
60
|
export interface RenderOptions {
|
|
60
61
|
/** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
|
|
61
62
|
schema?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The read-model scaffold. Default `'commented'`: every model carries the authz decision
|
|
65
|
+
* as a commented stanza (same guidance as the db:check gate — the model fails the gate
|
|
66
|
+
* until it is authored). A preset name (`'public-read'`) stamps that stanza uncommented
|
|
67
|
+
* into every rendered model — an explicit, one-shot, operator-invoked choice that lands
|
|
68
|
+
* as reviewable code. Grants are authored, never inherited; there is no runtime default.
|
|
69
|
+
*/
|
|
70
|
+
abilities?: string;
|
|
71
|
+
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
72
|
+
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
73
|
+
derived?: DerivedRenderResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `--abilities` presets db:pull can stamp. Deliberately few: `public-read` is the
|
|
78
|
+
* dominant brownfield shape (public reference data, admin-managed). Anything else is a
|
|
79
|
+
* per-model edit — the decision belongs in the file, not in a flag grammar.
|
|
80
|
+
*/
|
|
81
|
+
export const ABILITY_PRESETS: Record<string, string> = {
|
|
82
|
+
'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The scaffold stanza for one model: the commented decision surface (guide text says the
|
|
87
|
+
* same thing the db:check gate failure says — one voice, two doorways), or a preset
|
|
88
|
+
* stamped uncommented. An unknown preset throws — grants are authored, never guessed.
|
|
89
|
+
*/
|
|
90
|
+
function abilitiesStanza(mode: string): string {
|
|
91
|
+
if (mode === 'commented') {
|
|
92
|
+
return [
|
|
93
|
+
' // Declare the read model — db:check fails this model until its authz is authored:',
|
|
94
|
+
` // abilities: [can('read')], // public data`,
|
|
95
|
+
` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
|
|
96
|
+
' // private: true, // not part of the data API',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
const preset = ABILITY_PRESETS[mode];
|
|
100
|
+
if (!preset) {
|
|
101
|
+
throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
|
|
102
|
+
}
|
|
103
|
+
return ` ${preset}`;
|
|
62
104
|
}
|
|
63
105
|
|
|
64
106
|
/** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
|
|
@@ -210,8 +252,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
210
252
|
|
|
211
253
|
if (col.default != null && !serial) {
|
|
212
254
|
const d = renderDefault(col.default);
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
// No builder maps it → declare it verbatim with the escape hatch. RAW, not the
|
|
256
|
+
// normalized form — stripping a trailing cast here could change semantics
|
|
257
|
+
// (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
|
|
258
|
+
// Drift closed by construction: pull output converges instead of shedding a TODO.
|
|
259
|
+
expr += d ?? `.defaultSql(sql\`${col.default}\`)`;
|
|
215
260
|
}
|
|
216
261
|
|
|
217
262
|
const validate = validates.get(col.name);
|
|
@@ -236,8 +281,14 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
236
281
|
items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
|
|
237
282
|
}
|
|
238
283
|
for (const ix of table.indexes) {
|
|
239
|
-
|
|
284
|
+
// Plain entries render as field keys; canonical-text entries (expressions, DESC,
|
|
285
|
+
// opclass) render as raw sql`` args — the same form the builder declares (Brick E).
|
|
286
|
+
const entry = (c: string): string =>
|
|
287
|
+
/^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${c}\``;
|
|
288
|
+
let s = `index(${ix.columns.map(entry).join(', ')})`;
|
|
289
|
+
if (ix.using) s += `.using(${tsLiteral(ix.using)})`;
|
|
240
290
|
if (ix.unique) s += '.unique()';
|
|
291
|
+
if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
|
|
241
292
|
if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
|
|
242
293
|
items.push(s);
|
|
243
294
|
}
|
|
@@ -261,7 +312,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
261
312
|
}
|
|
262
313
|
|
|
263
314
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
264
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string {
|
|
315
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented'): string {
|
|
265
316
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
266
317
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
267
318
|
const validates = new Map<string, string>();
|
|
@@ -275,23 +326,52 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
275
326
|
|
|
276
327
|
const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
|
|
277
328
|
const constraints = renderConstraintsBlock(table, tableChecks, known);
|
|
329
|
+
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
330
|
+
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
331
|
+
// put it, proving the position is mechanical-edit-friendly).
|
|
332
|
+
const stanza = abilitiesStanza(abilities);
|
|
278
333
|
|
|
279
|
-
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
334
|
+
return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
|
|
280
335
|
}
|
|
281
336
|
|
|
282
337
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
283
|
-
function importHeader(body: string): string {
|
|
338
|
+
function importHeader(body: string, extra: string[] = []): string {
|
|
284
339
|
const symbols = ['defineModel', 'field'];
|
|
340
|
+
// `can` only when a stanza is stamped UNCOMMENTED (line starts with spaces, not `//`) —
|
|
341
|
+
// the commented scaffold must not leave an unused import behind.
|
|
342
|
+
if (/^\s*abilities: \[can\(/m.test(body)) symbols.push('can');
|
|
285
343
|
if (/\bunique\(/.test(body)) symbols.push('unique');
|
|
286
344
|
if (/\bcheck\(/.test(body)) symbols.push('check');
|
|
287
345
|
if (/\bindex\(/.test(body)) symbols.push('index');
|
|
288
346
|
if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
|
|
347
|
+
if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
|
|
289
348
|
if (/\bsql`/.test(body)) symbols.push('sql');
|
|
349
|
+
for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
|
|
290
350
|
const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
|
|
291
351
|
if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
|
|
292
352
|
return imports.join('\n');
|
|
293
353
|
}
|
|
294
354
|
|
|
355
|
+
/** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
|
|
356
|
+
function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multiline = false): string {
|
|
357
|
+
const models = multiline
|
|
358
|
+
? `export const models = [\n${modelNames.map((n) => ` ${n},`).join('\n')}\n];`
|
|
359
|
+
: `export const models = [${modelNames.join(', ')}];`;
|
|
360
|
+
const parts = [models];
|
|
361
|
+
const keys = ['models'];
|
|
362
|
+
if (derived?.sequenceNames.length) {
|
|
363
|
+
parts.push(`export const sequences = [${derived.sequenceNames.join(', ')}];`);
|
|
364
|
+
keys.push('sequences');
|
|
365
|
+
}
|
|
366
|
+
if (derived?.names.length) {
|
|
367
|
+
parts.push(`export const derived = [${derived.names.join(', ')}];`);
|
|
368
|
+
keys.push('derived');
|
|
369
|
+
}
|
|
370
|
+
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
371
|
+
parts.push(`export const modules = [appModule];`);
|
|
372
|
+
return parts.join('\n\n');
|
|
373
|
+
}
|
|
374
|
+
|
|
295
375
|
/**
|
|
296
376
|
* Render a whole snapshot as a single Models module: the import, one block per table (scoped
|
|
297
377
|
* to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
|
|
@@ -303,11 +383,16 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
303
383
|
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
304
384
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
305
385
|
|
|
306
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums));
|
|
307
|
-
|
|
386
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented'));
|
|
387
|
+
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
388
|
+
// reference, before the module wrapper that composes all three.
|
|
389
|
+
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
390
|
+
|
|
391
|
+
// Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
|
|
392
|
+
// so a fresh brownfield project lands on the same shape the framework composes.
|
|
393
|
+
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
|
|
308
394
|
|
|
309
|
-
const header = importHeader(
|
|
310
|
-
const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
|
|
395
|
+
const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
|
|
311
396
|
|
|
312
397
|
return [header, ...blocks, footer].join('\n\n') + '\n';
|
|
313
398
|
}
|
|
@@ -349,7 +434,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
349
434
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
350
435
|
|
|
351
436
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
352
|
-
const block = renderModelBlock(t, known, enums);
|
|
437
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented');
|
|
353
438
|
const crossImports = referencedTables(t, known).map(
|
|
354
439
|
(target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
|
|
355
440
|
);
|
|
@@ -357,11 +442,19 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
357
442
|
return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
|
|
358
443
|
});
|
|
359
444
|
|
|
445
|
+
// The index carries the module wrapper (composition is the norm) and the derived layer
|
|
446
|
+
// (B5 — it references the model variables the index imports); model files carry only
|
|
447
|
+
// their own model.
|
|
360
448
|
const names = tables.map((t) => modelVarName(t.table));
|
|
449
|
+
const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
|
|
361
450
|
const index = [
|
|
362
|
-
|
|
451
|
+
[
|
|
452
|
+
`import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
|
|
453
|
+
...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
|
|
454
|
+
].join('\n'),
|
|
363
455
|
`export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
|
|
364
|
-
|
|
456
|
+
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
457
|
+
moduleFooter(names, opts.derived, true),
|
|
365
458
|
].join('\n\n');
|
|
366
459
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
367
460
|
|
package/src/cli/models-path.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* models-path — where the Models barrel lives.
|
|
3
3
|
*
|
|
4
|
-
* One home for schema: `db/models/` (
|
|
4
|
+
* One home for schema: `db/models/` — state (tables) and compute (descriptors). The
|
|
5
5
|
* CLI looks there first, falling back to the legacy top-level `models/` so
|
|
6
6
|
* existing consumers keep working without a flag. An explicit `--models`
|
|
7
7
|
* always wins. When neither home exists, the NEW home is returned so error
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics for a crashed ops-Lambda invoke.
|
|
3
|
+
*
|
|
4
|
+
* A consumer's ops handler can fail to *boot* — a broken top-level import (a dependency missing
|
|
5
|
+
* from the deployed bundle, a version skew, a bad path). Lambda then returns a FunctionError with
|
|
6
|
+
* the opaque `Runtime exited with error: exit status 1` for EVERY action, giving the operator no
|
|
7
|
+
* signal that it's the handler failing to start (not their command) and no path to the cause. The
|
|
8
|
+
* CLI turns that into a report naming the real cause by reading the handler's CloudWatch logs; the
|
|
9
|
+
* pure detection/extraction/formatting lives here, the AWS fetch in aws.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether a Lambda FunctionError message is the handler failing to boot / crashing the runtime — an
|
|
14
|
+
* uncaught init error worth chasing into CloudWatch — rather than an already-descriptive error. Only
|
|
15
|
+
* these opaque cases trigger the log fetch; a clear message passes through untouched.
|
|
16
|
+
*/
|
|
17
|
+
export function isOpsHandlerCrash(message: string): boolean {
|
|
18
|
+
return /Runtime exited|exited with error|Runtime\.(ImportModuleError|UserCodeSyntaxError|HandlerNotFound)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b/i.test(
|
|
19
|
+
message,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Log lines worth surfacing: the import error, a runtime error class, or a thrown Error/stack. */
|
|
24
|
+
const SIGNAL = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Runtime\.\w+|(?:Reference|Type|Syntax|Range)?Error:/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pull the diagnostic lines from raw CloudWatch log text for a crashed ops invoke — the import
|
|
28
|
+
* error / stack frame that names the missing dependency — so an opaque "exit status 1" becomes the
|
|
29
|
+
* real cause. De-duplicates and caps output. Returns null when nothing diagnostic is present.
|
|
30
|
+
*/
|
|
31
|
+
export function extractCrashDiagnosis(logText: string): string | null {
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const raw of logText.split('\n')) {
|
|
35
|
+
const line = raw.trim();
|
|
36
|
+
if (!line || !SIGNAL.test(line) || seen.has(line)) continue;
|
|
37
|
+
seen.add(line);
|
|
38
|
+
out.push(line);
|
|
39
|
+
if (out.length >= 6) break;
|
|
40
|
+
}
|
|
41
|
+
return out.length ? out.join('\n') : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
|
|
46
|
+
* handler, not your command" framing, and either the real cause from CloudWatch or a where-to-look
|
|
47
|
+
* hint when the logs couldn't be read.
|
|
48
|
+
*/
|
|
49
|
+
export function formatOpsCrashReport(
|
|
50
|
+
baseMessage: string,
|
|
51
|
+
logGroup?: string,
|
|
52
|
+
diagnosis?: string | null,
|
|
53
|
+
): string {
|
|
54
|
+
const lines = [
|
|
55
|
+
baseMessage,
|
|
56
|
+
'',
|
|
57
|
+
'The ops handler crashed at startup — usually a missing dependency in the deployed bundle, not a problem with this command.',
|
|
58
|
+
];
|
|
59
|
+
if (diagnosis) {
|
|
60
|
+
lines.push(
|
|
61
|
+
'',
|
|
62
|
+
`From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
|
|
63
|
+
diagnosis,
|
|
64
|
+
'',
|
|
65
|
+
'Fix the import/dependency and redeploy.',
|
|
66
|
+
);
|
|
67
|
+
} else {
|
|
68
|
+
lines.push('Check its CloudWatch logs for the failing top-level import, then redeploy.');
|
|
69
|
+
}
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline-loader — the CLI's window onto @everystack/server/pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The CLI keeps NO compile-time dependency on @everystack/server (the two are
|
|
5
|
+
* mutual peers; hard-importing would be a project-reference cycle — the same
|
|
6
|
+
* reason plugin.ts inlines its types). So the orchestrator's types are mirrored
|
|
7
|
+
* structurally here and the runtime module is loaded lazily via a variable
|
|
8
|
+
* specifier. Every CLI verb that reaches the pipeline framework (pipeline:run,
|
|
9
|
+
* and the db:sync / db:plan visibility) goes through this one door.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type PipelineLane = 'rebuild' | 'curate' | 'all';
|
|
13
|
+
|
|
14
|
+
export interface PipelineStage {
|
|
15
|
+
id: string;
|
|
16
|
+
lane: 'automated' | 'curated' | 'frozen';
|
|
17
|
+
dependsOn?: string[];
|
|
18
|
+
after?: string[];
|
|
19
|
+
heavy?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface Pipeline {
|
|
22
|
+
stages: PipelineStage[];
|
|
23
|
+
}
|
|
24
|
+
export interface RunPipelineOptions {
|
|
25
|
+
runId: string;
|
|
26
|
+
lane?: PipelineLane;
|
|
27
|
+
only?: string;
|
|
28
|
+
from?: string;
|
|
29
|
+
to?: string;
|
|
30
|
+
resume?: boolean;
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
continueOnError?: boolean;
|
|
33
|
+
actor?: string | null;
|
|
34
|
+
gitRef?: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface StageRunResult {
|
|
37
|
+
stageId: string;
|
|
38
|
+
outcome: 'applied' | 'skipped' | 'failed' | 'dry-run';
|
|
39
|
+
rows?: number;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PipelineRunOutcome {
|
|
43
|
+
runId: string;
|
|
44
|
+
plan: string[];
|
|
45
|
+
results: StageRunResult[];
|
|
46
|
+
failed: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PipelineRunSummary {
|
|
49
|
+
runId: string;
|
|
50
|
+
at: string | null;
|
|
51
|
+
applied: number;
|
|
52
|
+
failed: number;
|
|
53
|
+
failedStages: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ServerPipeline {
|
|
57
|
+
runPipeline(runner: unknown, pipeline: Pipeline, options: RunPipelineOptions): Promise<PipelineRunOutcome>;
|
|
58
|
+
latestRunId(query: (sql: string) => Promise<any[]>): Promise<string | null>;
|
|
59
|
+
readLastRun(query: (sql: string) => Promise<any[]>): Promise<PipelineRunSummary | null>;
|
|
60
|
+
topoSortStages(stages: PipelineStage[]): PipelineStage[];
|
|
61
|
+
selectStages(ordered: PipelineStage[], opts: Record<string, unknown>): PipelineStage[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lazily load the orchestrator from the @everystack/server peer (runtime only). */
|
|
65
|
+
export async function loadServerPipeline(): Promise<ServerPipeline> {
|
|
66
|
+
const spec = '@everystack/server/pipeline';
|
|
67
|
+
return (await import(spec)) as ServerPipeline;
|
|
68
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline-path — where the consumer's pipeline definition lives.
|
|
3
|
+
*
|
|
4
|
+
* Beside the schema homes (`db/models/`, `db/backfills/`), the
|
|
5
|
+
* pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
|
|
6
|
+
* exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
|
|
7
|
+
* always wins; when neither home exists the primary is returned so error
|
|
8
|
+
* messages point at the current convention.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
/** Candidate pipeline entrypoints, in preference order. */
|
|
14
|
+
export const PIPELINE_HOMES = ['db/pipeline.ts', 'db/pipeline/index.ts'] as const;
|
|
15
|
+
|
|
16
|
+
export function resolvePipelinePath(
|
|
17
|
+
flag?: string,
|
|
18
|
+
exists: (p: string) => boolean = existsSync,
|
|
19
|
+
): string {
|
|
20
|
+
if (flag) return flag;
|
|
21
|
+
for (const home of PIPELINE_HOMES) {
|
|
22
|
+
if (exists(home)) return home;
|
|
23
|
+
}
|
|
24
|
+
return PIPELINE_HOMES[0];
|
|
25
|
+
}
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { ModelDescriptor, FieldSpec } from '@everystack/model';
|
|
23
|
-
import type { TableSchema, EnumType, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
|
|
24
|
-
import type
|
|
22
|
+
import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
|
|
23
|
+
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
|
|
24
|
+
import { nextvalSequence, type RenameMap } from './schema-diff.js';
|
|
25
25
|
|
|
26
26
|
/** `authorId` -> `author_id`. SQL identifiers are snake_case. */
|
|
27
|
-
function toSnakeCase(name: string): string {
|
|
27
|
+
export function toSnakeCase(name: string): string {
|
|
28
28
|
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -48,20 +48,25 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
48
48
|
} else if (con.kind === 'check') {
|
|
49
49
|
checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
|
|
50
50
|
} else if (con.kind === 'index') {
|
|
51
|
-
|
|
51
|
+
// Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
|
|
52
|
+
// DESC, opclass) pass verbatim — they are already SQL (Brick E).
|
|
53
|
+
const cols = con.columns.map((c, i) => (con.rawPositions?.[i] ? c : toSnakeCase(c)));
|
|
52
54
|
// Disambiguate multiple indexes over the same columns — e.g. a full index plus a
|
|
53
55
|
// partial one (`familyId` and `familyId WHERE status='active'`): the name must be
|
|
54
56
|
// unique within the table for CREATE to succeed. The diff matches indexes by content
|
|
55
|
-
// (
|
|
56
|
-
// round-trip.
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// (entries + uniqueness + predicate + method + INCLUDE), never by name, so the suffix
|
|
58
|
+
// never affects the round-trip. Raw entries sanitize to identifier-safe name parts.
|
|
59
|
+
const nameParts = cols.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
|
|
60
|
+
let name = `${model.table}_${nameParts.join('_')}_index`;
|
|
61
|
+
for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${nameParts.join('_')}_index_${n}`;
|
|
59
62
|
usedIndexNames.add(name);
|
|
60
63
|
indexes.push({
|
|
61
64
|
name,
|
|
62
65
|
columns: cols,
|
|
63
66
|
unique: con.isUnique,
|
|
64
67
|
...(con.predicate ? { where: con.predicate } : {}),
|
|
68
|
+
...(con.method ? { using: con.method } : {}),
|
|
69
|
+
...(con.includeColumns?.length ? { include: con.includeColumns.map(toSnakeCase) } : {}),
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
}
|
|
@@ -209,6 +214,7 @@ function defaultExpr(spec: FieldSpec): string | null {
|
|
|
209
214
|
switch (spec.defaultKind) {
|
|
210
215
|
case 'now': return 'now()';
|
|
211
216
|
case 'random': return 'gen_random_uuid()';
|
|
217
|
+
case 'sql': return String(spec.default); // .defaultSql() — the author's SQL, verbatim
|
|
212
218
|
case 'value':
|
|
213
219
|
return spec.isArray && Array.isArray(spec.default) ? arrayLiteral(spec.default) : literal(spec.default);
|
|
214
220
|
default: return null;
|
|
@@ -251,7 +257,12 @@ export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKe
|
|
|
251
257
|
let s = `"${sqlName}" ${serial ?? pgType(spec)}`;
|
|
252
258
|
if (inlinePrimaryKey) s += ' PRIMARY KEY';
|
|
253
259
|
const def = serial ? null : defaultExpr(spec);
|
|
254
|
-
|
|
260
|
+
// A `defaultSql` nextval draws from an EXISTING sequence, possibly owned by a table
|
|
261
|
+
// created later in the same migration — held out of the column line (compileMigration
|
|
262
|
+
// re-attaches it as a trailing SET DEFAULT). Never the serial shorthand: that would
|
|
263
|
+
// mint a NEW sequence and silently detach the column from the one it declared.
|
|
264
|
+
const heldSequenceDraw = spec.defaultKind === 'sql' && nextvalSequence(def) != null;
|
|
265
|
+
if (def && !heldSequenceDraw) s += ` DEFAULT ${def}`;
|
|
255
266
|
if (spec.isNotNull) s += ' NOT NULL';
|
|
256
267
|
return s;
|
|
257
268
|
}
|
|
@@ -485,6 +496,26 @@ export function compileEnums(models: ModelDescriptor[]): EnumType[] {
|
|
|
485
496
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
486
497
|
}
|
|
487
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Standalone-sequence descriptors → the snapshot shape the diff and fingerprint consume.
|
|
501
|
+
* PostgreSQL's defaults (start/increment of 1) are omitted, so a declaration that omits
|
|
502
|
+
* them reaches the same canonical form a live default-configured sequence introspects to.
|
|
503
|
+
*/
|
|
504
|
+
export function compileSequences(sequences: readonly SequenceDescriptor[]): SequenceSchema[] {
|
|
505
|
+
return sequences
|
|
506
|
+
.map((s) => ({
|
|
507
|
+
name: s.name,
|
|
508
|
+
as: s.as,
|
|
509
|
+
...(s.start != null && s.start !== 1 ? { start: s.start } : {}),
|
|
510
|
+
...(s.increment != null && s.increment !== 1 ? { increment: s.increment } : {}),
|
|
511
|
+
}))
|
|
512
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The DDL renderer lives in schema-diff (emitOne needs it; this file already imports
|
|
516
|
+
// schema-diff, so defining it there avoids a cycle) — re-exported here beside its compiler.
|
|
517
|
+
export { sequenceCreateSql } from './schema-diff.js';
|
|
518
|
+
|
|
488
519
|
/**
|
|
489
520
|
* Foreign-key constraints for a model — its `.references()` fields and its table-level
|
|
490
521
|
* `foreignKey(...)` constraints, via `modelForeignKeys` — as `ALTER TABLE` statements. These
|