@everystack/cli 0.3.31 → 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 +2 -2
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +71 -20
- package/src/cli/commands/db-pull.ts +37 -5
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- 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 +176 -10
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -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/pipeline-path.ts +1 -1
- 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/README.md
CHANGED
|
@@ -216,7 +216,7 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
|
|
|
216
216
|
# Model-driven schema (@everystack/model)
|
|
217
217
|
everystack db:generate # diff Models vs the live DB → next migration
|
|
218
218
|
everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
|
|
219
|
-
everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/
|
|
219
|
+
everystack db:reconcile # deploy the derived layer (functions/views/matviews/triggers) from the declared descriptors in db/models — no migrations
|
|
220
220
|
everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
|
|
221
221
|
everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
|
|
222
222
|
everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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>",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"structured-headers": "1.0.1",
|
|
83
83
|
"tsx": "4.21.0",
|
|
84
84
|
"typescript": "5.9.3",
|
|
85
|
-
"@everystack/model": "0.
|
|
85
|
+
"@everystack/model": "0.4.0"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
88
|
"@everystack/server": ">=0.1.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -308,8 +308,10 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
308
308
|
/** The four DML privileges, sorted — the expansion of a `manage` ability. */
|
|
309
309
|
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
310
310
|
|
|
311
|
-
/** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD.
|
|
312
|
-
|
|
311
|
+
/** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD.
|
|
312
|
+
* `execute` is a FUNCTION ability — defineModel rejects it on tables, so it never
|
|
313
|
+
* reaches the table grant compiler (the throw below keeps that contract loud). */
|
|
314
|
+
const VERB: Record<Exclude<Ability['action'], 'manage' | 'execute'>, string> = {
|
|
313
315
|
read: 'SELECT',
|
|
314
316
|
create: 'INSERT',
|
|
315
317
|
update: 'UPDATE',
|
|
@@ -318,6 +320,7 @@ const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
|
|
|
318
320
|
|
|
319
321
|
/** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
|
|
320
322
|
function verbsFor(action: Ability['action']): string[] {
|
|
323
|
+
if (action === 'execute') throw new Error(`can('execute') is a function ability — it has no table privilege (defineModel rejects it; this is unreachable).`);
|
|
321
324
|
return action === 'manage' ? [...CRUD] : [VERB[action]];
|
|
322
325
|
}
|
|
323
326
|
|
package/src/cli/backfill.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* unlike `schema_log`, which stays a write-only memoir. The distrust that
|
|
9
9
|
* killed the migration tape doesn't transfer: nothing is numbered, nothing
|
|
10
10
|
* is watermarked, and identity is the NORMALIZED CONTENT HASH (the same
|
|
11
|
-
* comment-insensitive, dollar-quote-aware lexing as
|
|
11
|
+
* comment-insensitive, dollar-quote-aware lexing as the reconciler) — renaming a
|
|
12
12
|
* file or editing a comment is a no-op, never a re-run.
|
|
13
13
|
*
|
|
14
14
|
* One-shot jobs are IMMUTABLE. A file whose name already ran on this
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* decision 14).
|
|
5
5
|
*
|
|
6
6
|
* db:template:refresh [--database-url <url>] [--models <barrel>]
|
|
7
|
-
* [--
|
|
7
|
+
* [--seed "<cmd>"] [--no-seed]
|
|
8
8
|
* db:branch # mint (or find) the current branch's DB
|
|
9
9
|
* db:branch --list # every branch DB, mapped to its branch
|
|
10
10
|
* db:branch --drop --confirm # drop the current branch's DB
|
|
@@ -39,7 +39,7 @@ import { currentGitRef } from '../state-apply.js';
|
|
|
39
39
|
import { fingerprintModels } from '../schema-fingerprint.js';
|
|
40
40
|
import { resolveDbSource, type DbSource } from '../db-source.js';
|
|
41
41
|
import { resolveModelsPath } from '../models-path.js';
|
|
42
|
-
import {
|
|
42
|
+
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
43
43
|
import { loadModels } from './db-generate.js';
|
|
44
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
45
45
|
|
|
@@ -92,7 +92,30 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
|
|
|
92
92
|
fail(err.message);
|
|
93
93
|
process.exit(1);
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// B7 tombstone — db/sql is retired and no longer read.
|
|
96
|
+
try {
|
|
97
|
+
if (flags['sql-dir']) {
|
|
98
|
+
fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const retired = await retiredSqlDirAnywhere(flags.models);
|
|
102
|
+
if (retired) {
|
|
103
|
+
fail(retired);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
} catch (err: any) {
|
|
107
|
+
fail(err.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
// The declared derived layer + sequences — branch/template DBs carry the SAME stream
|
|
111
|
+
// every other verb deploys, or a minted branch is silently missing its compute.
|
|
112
|
+
let declaredDb: DeclaredDerived | null = null;
|
|
113
|
+
try {
|
|
114
|
+
declaredDb = await loadDeclaredDerived(flags.models);
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
fail(err.message);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
96
119
|
|
|
97
120
|
let seedCommand: string | null = null;
|
|
98
121
|
if (flags['no-seed'] === 'true') {
|
|
@@ -109,7 +132,8 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
|
|
|
109
132
|
step(`Rebuilding ${template} from the declared state (drop → create → sync both layers → seed)...`);
|
|
110
133
|
try {
|
|
111
134
|
const result = await executeTemplateRefresh(url, models, {
|
|
112
|
-
|
|
135
|
+
declared: declaredDb?.objects,
|
|
136
|
+
sequences: declaredDb?.sequences,
|
|
113
137
|
actor: process.env.USER ?? null,
|
|
114
138
|
gitRef: currentGitRef(),
|
|
115
139
|
seed: seedCommand === null ? null : (templateUrl) => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* invariant: the merged declared state must compose, and generated
|
|
4
4
|
* artifacts must match regeneration).
|
|
5
5
|
*
|
|
6
|
-
* db:check [--models <barrel>] [--
|
|
6
|
+
* db:check [--models <barrel>] [--schema-out <file.ts>]
|
|
7
7
|
* [--database-url <url>] [--json]
|
|
8
8
|
*
|
|
9
9
|
* Two rings, both from the checkout alone (no target database is ever
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* git can't see, like two branches declaring the same table, fails
|
|
14
14
|
* here); the generated drizzle schema matches a byte-for-byte
|
|
15
15
|
* regeneration (hand-edited artifacts and forgotten `db:generate` runs
|
|
16
|
-
* fail loudly);
|
|
16
|
+
* fail loudly); a leftover db/sql directory fails with the migration
|
|
17
|
+
* path (B7 — descriptors are the single home).
|
|
17
18
|
*
|
|
18
19
|
* EPHEMERAL COMPOSE — with a scratch PostgreSQL (`--database-url` /
|
|
19
20
|
* DATABASE_URL), build the merged declared state FROM SCRATCH on an
|
|
@@ -28,24 +29,25 @@
|
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
import fs from 'node:fs/promises';
|
|
31
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
32
|
+
import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
32
33
|
import { compileDeclaredState } from '../declared-diff.js';
|
|
33
34
|
import { compileMigration } from '../migration-compile.js';
|
|
34
35
|
import { compileDrizzleSource } from '../schema-source.js';
|
|
35
|
-
import { parseSqlSources, type SourceFile } from '../derived-source.js';
|
|
36
36
|
import { currentGitRef } from '../state-apply.js';
|
|
37
37
|
import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
|
|
38
38
|
import { resolveModelsPath } from '../models-path.js';
|
|
39
|
-
import { readSqlDirIfPresent } from './db-sync.js';
|
|
40
39
|
import { loadModels } from './db-generate.js';
|
|
40
|
+
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
41
|
+
import type { SequenceDescriptor } from '@everystack/model';
|
|
42
|
+
import type { SourceObject } from '../derived-source.js';
|
|
41
43
|
import { findReadAuthzGaps } from '../authz-lint.js';
|
|
44
|
+
import { findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings } from '../derived-lint.js';
|
|
42
45
|
import { step, success, fail, info, warn } from '../output.js';
|
|
43
46
|
|
|
44
47
|
// The role derivation moved to the shared builder (db-build) with brick
|
|
45
48
|
// 5-remainder; re-exported here because db:check is where it grew up.
|
|
46
49
|
export { rolesFromContract } from '../db-build.js';
|
|
47
50
|
|
|
48
|
-
const DEFAULT_SQL_DIR = 'db/sql';
|
|
49
51
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
50
52
|
|
|
51
53
|
// ---------------------------------------------------------------------------
|
|
@@ -53,7 +55,8 @@ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
|
53
55
|
// ---------------------------------------------------------------------------
|
|
54
56
|
|
|
55
57
|
export interface CheckFinding {
|
|
56
|
-
|
|
58
|
+
/** `warn` is visible-but-never-fatal — the matview snapshot gate's channel. */
|
|
59
|
+
level: 'ok' | 'note' | 'warn' | 'fail';
|
|
57
60
|
area: 'models' | 'compose' | 'artifact' | 'compute' | 'authz';
|
|
58
61
|
message: string;
|
|
59
62
|
}
|
|
@@ -66,8 +69,15 @@ export interface StaticCheckInput {
|
|
|
66
69
|
artifactPath: string;
|
|
67
70
|
/** null = no generated schema artifact on disk. */
|
|
68
71
|
artifactSource: string | null;
|
|
69
|
-
/**
|
|
70
|
-
|
|
72
|
+
/** Set when a retired db/sql directory still carries .sql files — the tombstone
|
|
73
|
+
* message (with the migration path); a hard fail, never a shrug. */
|
|
74
|
+
sqlDirRetired?: string | null;
|
|
75
|
+
/** Standalone sequences the modules declare (state — in the from-scratch compose). */
|
|
76
|
+
sequences?: SequenceDescriptor[];
|
|
77
|
+
/** The declared derived descriptors (raw — the B2 gates read abilities, not SQL). */
|
|
78
|
+
derived?: DerivedDescriptor[] | null;
|
|
79
|
+
/** Set when the derived layer failed to COMPILE (e.g. reachability) — a hard fail. */
|
|
80
|
+
derivedError?: string;
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
@@ -98,7 +108,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
98
108
|
}
|
|
99
109
|
try {
|
|
100
110
|
compileDeclaredState(input.models);
|
|
101
|
-
const statements = compileMigration(input.models);
|
|
111
|
+
const statements = compileMigration(input.models, { sequences: input.sequences });
|
|
102
112
|
findings.push({ level: 'ok', area: 'compose', message: `declared state composes — ${statements.length} statement(s) from scratch` });
|
|
103
113
|
} catch (err: any) {
|
|
104
114
|
findings.push({ level: 'fail', area: 'compose', message: `the merged declared state does NOT compose: ${err.message}` });
|
|
@@ -116,13 +126,43 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
116
126
|
}
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
// The derived layer failing to COMPILE (reachability, cycles, undeclared trigger
|
|
130
|
+
// functions) is a hard fail — CI cannot shrug at a compile error.
|
|
131
|
+
if (input.derivedError) {
|
|
132
|
+
findings.push({
|
|
133
|
+
level: 'fail',
|
|
134
|
+
area: 'compute',
|
|
135
|
+
message: `the declared derived layer does NOT compile: ${input.derivedError}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The B2 gates over the declared derived layer: every relation makes its read decision
|
|
140
|
+
// (fail), every SECURITY DEFINER declares its callers (fail), and a matview over
|
|
141
|
+
// row-scoped sources is named for what it is — a snapshot with the refresher's eyes (warn).
|
|
142
|
+
if (input.derived?.length) {
|
|
143
|
+
const derivedGaps = [...findDerivedReadGaps(input.derived), ...findSecdefExecuteGaps(input.derived)];
|
|
144
|
+
for (const gap of derivedGaps) {
|
|
145
|
+
findings.push({ level: 'fail', area: 'authz', message: gap.message });
|
|
146
|
+
}
|
|
147
|
+
for (const warning of findMatviewSnapshotWarnings(input.derived)) {
|
|
148
|
+
findings.push({ level: 'warn', area: 'authz', message: warning.message });
|
|
149
|
+
}
|
|
150
|
+
if (derivedGaps.length === 0) {
|
|
151
|
+
findings.push({
|
|
152
|
+
level: 'ok',
|
|
153
|
+
area: 'authz',
|
|
154
|
+
message: `every derived relation declares its read surface and every SECURITY DEFINER its callers (${input.derived.length} descriptor(s))`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
119
159
|
if (input.artifactSource === null) {
|
|
120
160
|
findings.push({
|
|
121
161
|
level: 'note',
|
|
122
162
|
area: 'artifact',
|
|
123
163
|
message: `no generated schema at ${input.artifactPath} — nothing to match (db:generate writes it)`,
|
|
124
164
|
});
|
|
125
|
-
} else if (input.artifactSource === compileDrizzleSource(input.models)) {
|
|
165
|
+
} else if (input.artifactSource === compileDrizzleSource(input.models, { derived: input.derived ?? undefined })) {
|
|
126
166
|
findings.push({ level: 'ok', area: 'artifact', message: `${input.artifactPath} matches regeneration byte-for-byte` });
|
|
127
167
|
} else {
|
|
128
168
|
findings.push({
|
|
@@ -132,20 +172,10 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
132
172
|
});
|
|
133
173
|
}
|
|
134
174
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const parsed = parseSqlSources(input.sources);
|
|
140
|
-
findings.push({
|
|
141
|
-
level: 'ok',
|
|
142
|
-
area: 'compute',
|
|
143
|
-
message: `${parsed.objects.length} derived object(s) parsed from ${input.sources.length} db/sql file(s)`,
|
|
144
|
-
});
|
|
145
|
-
for (const w of parsed.warnings) findings.push({ level: 'note', area: 'compute', message: w });
|
|
146
|
-
} catch (err: any) {
|
|
147
|
-
findings.push({ level: 'fail', area: 'compute', message: `db/sql does not parse: ${err.message}` });
|
|
148
|
-
}
|
|
175
|
+
// B7 — db/sql is retired. Files still present would be silently unmanaged: fail
|
|
176
|
+
// with the migration path. (Absence is the normal state; nothing to report.)
|
|
177
|
+
if (input.sqlDirRetired) {
|
|
178
|
+
findings.push({ level: 'fail', area: 'compute', message: input.sqlDirRetired });
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
return findings;
|
|
@@ -177,14 +207,14 @@ export interface EphemeralComposeResult {
|
|
|
177
207
|
export async function executeEphemeralCompose(
|
|
178
208
|
adminUrl: string,
|
|
179
209
|
models: ModelDescriptor[],
|
|
180
|
-
|
|
181
|
-
opts: { actor?: string | null; gitRef?: string | null } = {},
|
|
210
|
+
opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[] } = {},
|
|
182
211
|
): Promise<EphemeralComposeResult> {
|
|
183
212
|
const database = `escheck_${process.pid}_${Date.now()}`;
|
|
184
213
|
await createDatabase(adminUrl, database);
|
|
185
214
|
try {
|
|
186
215
|
const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
|
|
187
|
-
|
|
216
|
+
declared: opts.declared,
|
|
217
|
+
sequences: opts.sequences,
|
|
188
218
|
actor: opts.actor ?? 'db:check',
|
|
189
219
|
gitRef: opts.gitRef ?? null,
|
|
190
220
|
});
|
|
@@ -198,7 +228,7 @@ export async function executeEphemeralCompose(
|
|
|
198
228
|
// The CLI shell.
|
|
199
229
|
// ---------------------------------------------------------------------------
|
|
200
230
|
|
|
201
|
-
const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', fail: '!' };
|
|
231
|
+
const MARK: Record<CheckFinding['level'], string> = { ok: '=', note: '·', warn: '~', fail: '!' };
|
|
202
232
|
|
|
203
233
|
export async function dbCheckCommand(flags: Record<string, string>): Promise<void> {
|
|
204
234
|
const modelsPath = resolveModelsPath(flags.models);
|
|
@@ -218,11 +248,30 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
218
248
|
} catch {
|
|
219
249
|
artifactSource = null;
|
|
220
250
|
}
|
|
221
|
-
|
|
251
|
+
// B7 tombstone — a retired db/sql directory still carrying .sql files is a FAIL
|
|
252
|
+
// finding (the message carries the migration path), never a silent skip. The retired
|
|
253
|
+
// --sql-dir flag is itself a FAIL finding here (the sibling verbs refuse it outright);
|
|
254
|
+
// detector errors (an unreadable directory) surface as the finding, never disarm it.
|
|
255
|
+
const sqlDirRetired = flags['sql-dir']
|
|
256
|
+
? await retiredSqlDirFlagRefusal(flags['sql-dir'])
|
|
257
|
+
: await retiredSqlDirAnywhere(flags.models).catch((err: any) => String(err?.message ?? err));
|
|
258
|
+
|
|
259
|
+
// The declared derived layer + sequences — the same stream every other verb consumes.
|
|
260
|
+
// A compile failure here (reachability, cycles) is a FAIL finding, not a shrug.
|
|
261
|
+
let declaredDb: DeclaredDerived | null = null;
|
|
262
|
+
let derivedError: string | undefined;
|
|
263
|
+
try {
|
|
264
|
+
declaredDb = await loadDeclaredDerived(flags.models);
|
|
265
|
+
} catch (err: any) {
|
|
266
|
+
derivedError = err.message;
|
|
267
|
+
}
|
|
222
268
|
|
|
223
|
-
const findings = runStaticChecks({
|
|
269
|
+
const findings = runStaticChecks({
|
|
270
|
+
modelsPath, models, modelsError, artifactPath, artifactSource, sqlDirRetired,
|
|
271
|
+
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
272
|
+
});
|
|
224
273
|
for (const f of findings) {
|
|
225
|
-
(f.level === 'fail' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
|
|
274
|
+
(f.level === 'fail' || f.level === 'warn' ? warn : info)(`${MARK[f.level]} ${f.area}: ${f.message}`);
|
|
226
275
|
}
|
|
227
276
|
|
|
228
277
|
let ephemeral: EphemeralComposeResult | null = null;
|
|
@@ -236,9 +285,11 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
236
285
|
} else {
|
|
237
286
|
step('Composing the declared state from scratch on an ephemeral database...');
|
|
238
287
|
try {
|
|
239
|
-
ephemeral = await executeEphemeralCompose(url, models!,
|
|
288
|
+
ephemeral = await executeEphemeralCompose(url, models!, {
|
|
240
289
|
actor: process.env.USER ?? 'db:check',
|
|
241
290
|
gitRef: currentGitRef(),
|
|
291
|
+
declared: declaredDb?.objects,
|
|
292
|
+
sequences: declaredDb?.sequences,
|
|
242
293
|
});
|
|
243
294
|
for (const line of ephemeral.report) info(` ${line}`);
|
|
244
295
|
} catch (err: any) {
|
|
@@ -30,6 +30,8 @@ import { resolveConfig, opsFunction } from '../config.js';
|
|
|
30
30
|
import { invokeAction } from '../aws.js';
|
|
31
31
|
import { step, success, fail, info, warn } from '../output.js';
|
|
32
32
|
import { loadModels } from './db-generate.js';
|
|
33
|
+
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
34
|
+
import type { SequenceDescriptor } from '@everystack/model';
|
|
33
35
|
|
|
34
36
|
export interface FingerprintStatus {
|
|
35
37
|
live: string;
|
|
@@ -42,11 +44,12 @@ export interface FingerprintStatus {
|
|
|
42
44
|
export async function computeFingerprintStatus(
|
|
43
45
|
runner: QueryRunner,
|
|
44
46
|
models: ModelDescriptor[] | null,
|
|
47
|
+
sequences?: SequenceDescriptor[],
|
|
45
48
|
): Promise<FingerprintStatus> {
|
|
46
49
|
const snapshot = await introspectSchema(runner);
|
|
47
50
|
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
48
51
|
const live = fingerprintLive(snapshot, contract).hash;
|
|
49
|
-
const declared = models ? fingerprintModels(models).hash : null;
|
|
52
|
+
const declared = models ? fingerprintModels(models, { sequences }).hash : null;
|
|
50
53
|
const unfingerprinted = mapUnfingerprintedRows((await runner(UNFINGERPRINTED_SQL)) as any[]);
|
|
51
54
|
return { live, declared, match: declared === null ? null : live === declared, unfingerprinted };
|
|
52
55
|
}
|
|
@@ -71,8 +74,10 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
71
74
|
|
|
72
75
|
const modelsPath = resolveModelsPath(flags.models);
|
|
73
76
|
let models: ModelDescriptor[] | null = null;
|
|
77
|
+
let sequences: SequenceDescriptor[] | undefined;
|
|
74
78
|
try {
|
|
75
79
|
models = await loadModels(modelsPath);
|
|
80
|
+
sequences = (await loadDeclaredDerived(flags.models))?.sequences;
|
|
76
81
|
} catch {
|
|
77
82
|
// Live-only mode: no models barrel — still useful (what fingerprint is this DB at?).
|
|
78
83
|
}
|
|
@@ -89,7 +94,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
try {
|
|
92
|
-
const status = await computeFingerprintStatus(runner, models);
|
|
97
|
+
const status = await computeFingerprintStatus(runner, models, sequences);
|
|
93
98
|
|
|
94
99
|
if (flags.json === 'true') {
|
|
95
100
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* db:generate --stage <name> [--name <label>] introspect the live DB → diff the Models
|
|
5
5
|
* → write the next drizzle migration file
|
|
6
|
+
* db:generate --dry-run the same edge, printed — NOTHING written
|
|
7
|
+
* (no migration, no journal, no schema refresh)
|
|
6
8
|
*
|
|
7
9
|
* The one generator — both layers in one ordered migration. It compiles the app's Models,
|
|
8
10
|
* introspects the deployed database through the ops Lambda `db:query` action (the same
|
|
@@ -24,11 +26,12 @@ import type { ModelDescriptor, Module } from '@everystack/model';
|
|
|
24
26
|
import { introspectSchema } from '../schema-introspect.js';
|
|
25
27
|
import { compileDrizzleSource } from '../schema-source.js';
|
|
26
28
|
import { compileModuleMigration } from '../migration-compile.js';
|
|
27
|
-
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
29
|
+
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, resolveSchemaOut, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
28
30
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
29
31
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
30
32
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
31
33
|
import { resolveModelsPath } from '../models-path.js';
|
|
34
|
+
import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived.js';
|
|
32
35
|
import { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
|
|
33
36
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
34
37
|
import { invokeAction } from '../aws.js';
|
|
@@ -76,7 +79,7 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
|
76
79
|
}
|
|
77
80
|
if (Array.isArray(mod.modules)) return mod.modules;
|
|
78
81
|
const models = mod.models ?? mod.default;
|
|
79
|
-
if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
|
|
82
|
+
if (Array.isArray(models)) return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
|
|
80
83
|
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -87,14 +90,14 @@ async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
|
87
90
|
* `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
|
|
88
91
|
* a clean migration history: clear `drizzle/`, then `db:generate --init`.
|
|
89
92
|
*/
|
|
90
|
-
async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
|
|
93
|
+
async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string, schemaOutRecord: string): Promise<void> {
|
|
91
94
|
step(`Loading modules from ${modelsPath}...`);
|
|
92
95
|
const modules = await loadModules(modelsPath);
|
|
93
96
|
const models = modules.flatMap((m) => m.models);
|
|
94
97
|
info(`${modules.length} module(s), ${models.length} model(s).`);
|
|
95
98
|
|
|
96
99
|
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
97
|
-
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
100
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: modules.flatMap((m) => m.derived) }), 'utf8');
|
|
98
101
|
|
|
99
102
|
const statements = compileModuleMigration(modules);
|
|
100
103
|
|
|
@@ -106,6 +109,7 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
|
|
|
106
109
|
journal = { version: '7', dialect: 'postgresql', entries: [] };
|
|
107
110
|
}
|
|
108
111
|
const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
|
|
112
|
+
nextJournal.schemaOut = schemaOutRecord; // D2 — flag-less later runs reuse it
|
|
109
113
|
const filePath = path.join(migrationsDir, filename);
|
|
110
114
|
await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
|
|
111
115
|
await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
|
|
@@ -117,18 +121,43 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
|
|
|
117
121
|
process.exit(0);
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/** Read the journal if present (null = no history yet). */
|
|
125
|
+
async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
|
|
121
134
|
const modelsPath = resolveModelsPath(flags.models);
|
|
122
135
|
const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
|
|
123
|
-
const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
|
|
124
136
|
const name = flags.name || 'generated';
|
|
125
137
|
const allowDrops = flags['allow-drops'] === 'true';
|
|
138
|
+
const dryRun = flags['dry-run'] === 'true';
|
|
139
|
+
|
|
140
|
+
// D2 — schema-out resolution: flag > journal-recorded > default. Recording the
|
|
141
|
+
// resolved path in the journal makes the relocated-artifact orphan unmintable:
|
|
142
|
+
// a later run without the flag keeps writing where the project writes.
|
|
143
|
+
const journal = await readJournal(migrationsDir);
|
|
144
|
+
const schemaOutRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
|
|
145
|
+
const schemaOut = path.resolve(schemaOutRes.path);
|
|
146
|
+
if (schemaOutRes.source === 'recorded') {
|
|
147
|
+
info(`schema-out: ${schemaOutRes.path} (recorded in the journal; override with --schema-out)`);
|
|
148
|
+
} else if (schemaOutRes.source === 'flag' && schemaOutRes.updateRecord && journal?.schemaOut) {
|
|
149
|
+
warn(`schema-out moves: ${journal.schemaOut} → ${schemaOutRes.path} — the journal record updates with this run; delete the old artifact.`);
|
|
150
|
+
}
|
|
126
151
|
|
|
127
152
|
// --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
|
|
128
153
|
// authz + package functions). The brownfield path below introspects the deployed DB and diffs.
|
|
129
154
|
if (flags.init === 'true') {
|
|
155
|
+
if (dryRun) {
|
|
156
|
+
fail('--dry-run previews the brownfield diff — --init is the greenfield generator (its output IS the whole migration; there is nothing cheaper to preview).');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
130
159
|
try {
|
|
131
|
-
await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
|
|
160
|
+
await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init', schemaOutRes.path);
|
|
132
161
|
} catch (err: any) {
|
|
133
162
|
fail(err.message);
|
|
134
163
|
process.exit(1);
|
|
@@ -137,7 +166,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
const apply = flags.apply === 'true';
|
|
169
|
+
if (dryRun && apply) {
|
|
170
|
+
fail('--dry-run and --apply are opposites — a dry run writes and executes nothing. Pick one.');
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
140
173
|
let models: ModelDescriptor[];
|
|
174
|
+
let declaredDb: DeclaredDerived | null = null;
|
|
141
175
|
let current;
|
|
142
176
|
let liveAuthz;
|
|
143
177
|
let runner!: QueryRunner;
|
|
@@ -145,12 +179,16 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
145
179
|
try {
|
|
146
180
|
step(`Loading models from ${modelsPath}...`);
|
|
147
181
|
models = await loadModels(modelsPath);
|
|
182
|
+
declaredDb = await loadDeclaredDerived(flags.models);
|
|
148
183
|
info(`${models.length} model(s).`);
|
|
149
184
|
// The drizzle runtime schema is a derived artifact — refresh it from the models
|
|
150
185
|
// every run (it needs no DB), so a relation-only change with no DDL diff still
|
|
151
|
-
// updates it. The drift-guard test asserts this file matches the models
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
// updates it. The drift-guard test asserts this file matches the models (and the
|
|
187
|
+
// typed derived relations — B6a). A dry run refreshes NOTHING — not even this.
|
|
188
|
+
if (!dryRun) {
|
|
189
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
190
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
|
|
191
|
+
}
|
|
154
192
|
const dbSource: DbSource = resolveDbSource(flags);
|
|
155
193
|
if (apply && dbSource.kind !== 'url') {
|
|
156
194
|
fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
|
|
@@ -179,14 +217,29 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
179
217
|
|
|
180
218
|
// One ordered migration carries both layers: data DDL first, then the authz reconcile
|
|
181
219
|
// (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
|
|
182
|
-
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz });
|
|
220
|
+
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences });
|
|
183
221
|
const unmodeled = unmodeledTables(models, current);
|
|
184
222
|
if (unmodeled.length) {
|
|
185
223
|
info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
|
|
186
224
|
}
|
|
187
225
|
console.log('');
|
|
188
226
|
if (statements.length === 0) {
|
|
189
|
-
success(
|
|
227
|
+
success(`db:generate — the live database already matches the models. ${dryRun ? 'Nothing to preview.' : 'No migration written.'}`);
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// D1 — --dry-run: the edge as SQL, nothing else. No migration file, no journal
|
|
232
|
+
// entry, no schema refresh — preview traffic belongs here (and db:diff computes
|
|
233
|
+
// the models-vs-models edge with no database at all).
|
|
234
|
+
if (dryRun) {
|
|
235
|
+
const classified = classifyGeneratedStatements(statements);
|
|
236
|
+
info(`dry run — ${statements.length} statement(s), nothing written:`);
|
|
237
|
+
console.log('');
|
|
238
|
+
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
239
|
+
console.log('');
|
|
240
|
+
if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back (shown as comments) — --allow-drops includes them.`);
|
|
241
|
+
if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups.`);
|
|
242
|
+
info('Nothing was written. Drop --dry-run to mint the migration, or `db:generate --apply` to run it directly on a dev database.');
|
|
190
243
|
process.exit(0);
|
|
191
244
|
}
|
|
192
245
|
|
|
@@ -209,7 +262,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
209
262
|
let outcome: StateSyncOutcome;
|
|
210
263
|
try {
|
|
211
264
|
outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
|
|
212
|
-
allowDrops, actor: process.env.USER ?? null, gitRef: currentGitRef(),
|
|
265
|
+
allowDrops, sequences: declaredDb?.sequences, actor: process.env.USER ?? null, gitRef: currentGitRef(),
|
|
213
266
|
});
|
|
214
267
|
} catch (err: any) {
|
|
215
268
|
fail(`Apply failed and rolled back: ${err.message}`);
|
|
@@ -228,14 +281,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
228
281
|
}
|
|
229
282
|
|
|
230
283
|
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
|
|
284
|
+
const { filename, journal: nextJournal } = planMigrationFile(
|
|
285
|
+
journal ?? { version: '7', dialect: 'postgresql', entries: [] }, name, Date.now(),
|
|
286
|
+
);
|
|
287
|
+
// D2 — the resolved schema-out path rides the journal, so the next flag-less run
|
|
288
|
+
// keeps writing where this project writes.
|
|
289
|
+
nextJournal.schemaOut = schemaOutRes.path;
|
|
239
290
|
const filePath = path.join(migrationsDir, filename);
|
|
240
291
|
try {
|
|
241
292
|
await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
|