@everystack/cli 0.4.53 → 0.4.55
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 +3 -2
- package/src/cli/commands/db-build.ts +153 -0
- package/src/cli/commands/db-check.ts +16 -2
- package/src/cli/commands/db-generate.ts +3 -3
- package/src/cli/commands/db-plan.ts +7 -1
- package/src/cli/commands/db-sync.ts +5 -2
- package/src/cli/declared-derived.ts +11 -0
- package/src/cli/edge-plan.ts +7 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/migration-generate.ts +21 -1
- package/src/cli/schema-compile.ts +9 -3
- package/src/cli/schema-fingerprint.ts +1 -1
- package/src/cli/schema-introspect.ts +13 -3
- package/src/cli/schema-source.ts +35 -7
- package/src/cli/state-apply.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.55",
|
|
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>",
|
|
@@ -164,6 +164,7 @@
|
|
|
164
164
|
"scripts": {
|
|
165
165
|
"test": "jest",
|
|
166
166
|
"build": "tsc --build",
|
|
167
|
-
"lint": "tsc --noEmit"
|
|
167
|
+
"lint": "tsc --noEmit",
|
|
168
|
+
"check:artifact": "tsx scripts/check-generated-artifact.ts"
|
|
168
169
|
}
|
|
169
170
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:build --database-url <url>` — build a database FROM the models, and KEEP it.
|
|
3
|
+
*
|
|
4
|
+
* db:build --database-url postgres://…/mydb [--models db/models]
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. Deleting a migration folder is only safe once the models alone can rebuild
|
|
7
|
+
* an identical database — local dev, the test DB, a new stage. Every other verb failed that:
|
|
8
|
+
* `db:check` proved buildability on an ephemeral database and then DROPPED it, while
|
|
9
|
+
* `db:sync` / `db:generate --apply` run the DIFF builder, which has no bootstrap phase and
|
|
10
|
+
* cannot start from nothing. A consumer tested our own documentation sentence ("a fresh
|
|
11
|
+
* database is db:check's compose") and found no verb behind it. This is that verb.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately THIN: `buildIntoDatabase` is the same core `db:check`'s compose, the dev
|
|
14
|
+
* template and `createEphemeralDatabase` already run — extensions, schemas, contract roles,
|
|
15
|
+
* declared functions before the policies that call them, then state and the derived layer.
|
|
16
|
+
* A second implementation of that ordering would be a second thing to keep true.
|
|
17
|
+
*
|
|
18
|
+
* The venue is EXPLICIT (`--database-url` only, never the ambient env, same rule as db:swap
|
|
19
|
+
* and db:export): this command writes a whole schema, so the target must be named on the
|
|
20
|
+
* command line and never inherited from a shell that happens to point at production.
|
|
21
|
+
*
|
|
22
|
+
* It REFUSES a database that already holds declared objects. Building into an existing
|
|
23
|
+
* database is `db:sync`'s job (dev) or `db:plan`/`db:apply`'s (a stage); a verb that silently
|
|
24
|
+
* did both is how a populated database gets clobbered by a command whose name says "build".
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
28
|
+
import { buildIntoDatabase } from '../db-build.js';
|
|
29
|
+
import { createUrlRunner } from '../db-source.js';
|
|
30
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
31
|
+
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
32
|
+
import { loadModels } from './db-generate.js';
|
|
33
|
+
import { currentGitRef } from '../state-apply.js';
|
|
34
|
+
import { step, info, success, fail, warn } from '../output.js';
|
|
35
|
+
|
|
36
|
+
/** Tables already in the target, outside the schemas PostgreSQL owns. */
|
|
37
|
+
const OCCUPANCY_SQL = `
|
|
38
|
+
SELECT n.nspname || '.' || c.relname AS identity
|
|
39
|
+
FROM pg_class c
|
|
40
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
41
|
+
WHERE c.relkind IN ('r', 'v', 'm')
|
|
42
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
43
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
44
|
+
ORDER BY 1
|
|
45
|
+
LIMIT 20;
|
|
46
|
+
`.trim();
|
|
47
|
+
|
|
48
|
+
export async function dbBuildCommand(flags: Record<string, string>): Promise<void> {
|
|
49
|
+
// Flag-only venue. The env is never consulted: `db:build` writes a whole schema, and an
|
|
50
|
+
// ambient DATABASE_URL must not be able to choose which database that happens to.
|
|
51
|
+
const url = flags['database-url'];
|
|
52
|
+
if (!url || url === 'true') {
|
|
53
|
+
fail('db:build needs --database-url <url> — the venue is explicit by design (the ambient environment never picks the target for a command that writes a whole schema).');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (flags.stage) {
|
|
57
|
+
fail('db:build has no --stage lane: building a fresh database is a local/dev operation. Evolve a deployed stage with db:plan → db:apply.');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const modelsPath = resolveModelsPath(flags.models);
|
|
62
|
+
let models: ModelDescriptor[];
|
|
63
|
+
try {
|
|
64
|
+
step(`Loading models from ${modelsPath}...`);
|
|
65
|
+
models = await loadModels(modelsPath);
|
|
66
|
+
info(`${models.length} model(s).`);
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
fail(err.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
if (flags['sql-dir']) {
|
|
74
|
+
fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const retired = await retiredSqlDirAnywhere(flags.models);
|
|
78
|
+
if (retired) {
|
|
79
|
+
fail(retired);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
fail(err.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The modules carry what `models` alone cannot: the derived layer, standalone sequences,
|
|
88
|
+
// and the extensions whose types the columns are declared in.
|
|
89
|
+
let declaredDb: DeclaredDerived | null = null;
|
|
90
|
+
try {
|
|
91
|
+
declaredDb = await loadDeclaredDerived(flags.models);
|
|
92
|
+
} catch (err: any) {
|
|
93
|
+
fail(err.message);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Refuse a target that already holds objects — see the header. Read on its own connection,
|
|
98
|
+
// closed before the build opens its own, so no session outlives the check it performed.
|
|
99
|
+
try {
|
|
100
|
+
const { runner, end } = await createUrlRunner(url);
|
|
101
|
+
let occupied: string[];
|
|
102
|
+
try {
|
|
103
|
+
occupied = (await runner(OCCUPANCY_SQL)).map((r: any) => String(r.identity));
|
|
104
|
+
} finally {
|
|
105
|
+
await end?.();
|
|
106
|
+
}
|
|
107
|
+
if (occupied.length > 0) {
|
|
108
|
+
fail(
|
|
109
|
+
`db:build refuses a database that already holds objects — it found ${occupied.length}: ${occupied.slice(0, 8).join(', ')}${occupied.length > 8 ? ', …' : ''}.\n`
|
|
110
|
+
+ ` This verb builds a FRESH database from the models. To evolve an existing one: db:sync (a dev database) or db:plan → db:apply (a stage).`,
|
|
111
|
+
);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
} catch (err: any) {
|
|
115
|
+
fail(`Could not read the target: ${err.message}`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
step('Building the declared state (extensions → schemas → roles → functions → state → derived)...');
|
|
120
|
+
let built;
|
|
121
|
+
try {
|
|
122
|
+
built = await buildIntoDatabase(url, models, {
|
|
123
|
+
declared: declaredDb?.objects,
|
|
124
|
+
sequences: declaredDb?.sequences,
|
|
125
|
+
extensions: declaredDb?.extensions,
|
|
126
|
+
actor: process.env.USER ?? null,
|
|
127
|
+
gitRef: currentGitRef(),
|
|
128
|
+
});
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
fail(`Build failed: ${err.message}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const line of built.report) info(line);
|
|
135
|
+
if (built.createdRoles.length) {
|
|
136
|
+
info(`Created ${built.createdRoles.length} contract role(s) (NOLOGIN, cluster-level): ${built.createdRoles.join(', ')}.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The bar is the same one db:check reports, and it is stated as a fact or not at all:
|
|
140
|
+
// a database that did not land on the models' fingerprint is not the declared state,
|
|
141
|
+
// however few statements were left over.
|
|
142
|
+
if (!built.converged || !built.fingerprintMatch) {
|
|
143
|
+
fail(
|
|
144
|
+
`The database was built but does NOT match the declared state (fingerprint ${built.fingerprint.slice(0, 12)}). `
|
|
145
|
+
+ `It is left in place for inspection — run db:generate --dry-run against it to see what differs.`,
|
|
146
|
+
);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
success(`Built and kept — the database IS the declared state at ${built.fingerprint.slice(0, 12)}.`);
|
|
151
|
+
warn('Roles are cluster-level: a role this build created is visible to every database in the cluster.');
|
|
152
|
+
info('Verify independently: everystack db:fingerprint --database-url <url> (expect MATCH), db:reconcile --check.');
|
|
153
|
+
}
|
|
@@ -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, loadModules } from './db-generate.js';
|
|
40
|
+
import { loadModels, loadModules, readJournal, DEFAULT_MIGRATIONS } from './db-generate.js';
|
|
41
|
+
import { resolveSchemaOut } from '../migration-generate.js';
|
|
41
42
|
import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '../model-barrel-lint.js';
|
|
42
43
|
import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
|
|
43
44
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
@@ -345,7 +346,17 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
345
346
|
} catch { /* a models-only barrel has no modules export — nothing to bootstrap */ }
|
|
346
347
|
}
|
|
347
348
|
|
|
348
|
-
|
|
349
|
+
// The SAME resolution db:generate uses — flag > journal-recorded > default. db:check read
|
|
350
|
+
// only the flag, so a project that had RELOCATED its artifact (recorded in the journal by
|
|
351
|
+
// db:generate, per its own help) had the two verbs disagree about which file is the
|
|
352
|
+
// artifact: generate wrote one path, check guarded another. A consumer worked around it by
|
|
353
|
+
// passing the flag explicitly in their CI script.
|
|
354
|
+
const journal = await readJournal(path.resolve(flags.dir || DEFAULT_MIGRATIONS));
|
|
355
|
+
const artifactRes = resolveSchemaOut(flags['schema-out'], journal, DEFAULT_SCHEMA_OUT);
|
|
356
|
+
const artifactPath = artifactRes.path;
|
|
357
|
+
if (artifactRes.source === 'recorded') {
|
|
358
|
+
info(`schema-out: ${artifactPath} (recorded in the journal; override with --schema-out)`);
|
|
359
|
+
}
|
|
349
360
|
let artifactSource: string | null = null;
|
|
350
361
|
try {
|
|
351
362
|
artifactSource = await fs.readFile(artifactPath, 'utf8');
|
|
@@ -438,6 +449,9 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
438
449
|
// ring proves the checkout against ITSELF, on an empty database. Whether an existing
|
|
439
450
|
// database IS this state is a different question with a different verb.
|
|
440
451
|
info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
|
|
452
|
+
// The ephemeral database is DROPPED. Saying so here is what stops this line being read
|
|
453
|
+
// as "you now have a database" — a consumer read exactly that into it, from our own docs.
|
|
454
|
+
info('The database this composed on was ephemeral and has been dropped. To build one you KEEP: everystack db:build --database-url <url>.');
|
|
441
455
|
} else {
|
|
442
456
|
success('db:check passed (static ring only — no database provided for the ephemeral compose).');
|
|
443
457
|
}
|
|
@@ -43,7 +43,7 @@ import { resolveConfig, opsFunction } from '../config.js';
|
|
|
43
43
|
import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
|
|
44
44
|
import { step, success, fail, info, warn } from '../output.js';
|
|
45
45
|
|
|
46
|
-
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
46
|
+
export const DEFAULT_MIGRATIONS = 'drizzle';
|
|
47
47
|
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
48
48
|
|
|
49
49
|
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work).
|
|
@@ -187,7 +187,7 @@ function reportOwnership(
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
190
|
+
export async function readJournal(migrationsDir: string): Promise<Journal | null> {
|
|
191
191
|
try {
|
|
192
192
|
return JSON.parse(await fs.readFile(path.join(migrationsDir, 'meta', '_journal.json'), 'utf8'));
|
|
193
193
|
} catch {
|
|
@@ -290,7 +290,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
290
290
|
|
|
291
291
|
// One ordered migration carries both layers: data DDL first, then the authz reconcile
|
|
292
292
|
// (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
|
|
293
|
-
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles });
|
|
293
|
+
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz, sequences: declaredDb?.sequences, governedRoles: declaredDb?.governedRoles, extensions: declaredDb?.extensions });
|
|
294
294
|
const unmodeled = unmodeledTables(models, current);
|
|
295
295
|
if (unmodeled.length) {
|
|
296
296
|
info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
|
|
@@ -172,8 +172,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
172
172
|
// The modules' widened governed-role set. A barrel that exports only `models` has none,
|
|
173
173
|
// which is the greenfield default: govern exactly what the models name.
|
|
174
174
|
let declaredGovernedRoles: string[] = [];
|
|
175
|
+
// The modules' extensions ride the same read. Without them the plan omits every
|
|
176
|
+
// CREATE EXTENSION the target lacks, and the apply fails on a type the plan promised.
|
|
177
|
+
let declaredExtensions: string[] = [];
|
|
175
178
|
try {
|
|
176
|
-
|
|
179
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
180
|
+
declaredGovernedRoles = declared?.governedRoles ?? [];
|
|
181
|
+
declaredExtensions = declared?.extensions ?? [];
|
|
177
182
|
} catch {
|
|
178
183
|
// The barrel's own compose errors surface on the paths that need the derived layer;
|
|
179
184
|
// a plan must not fail to mint because a module could not be read for this one field.
|
|
@@ -202,6 +207,7 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
202
207
|
// Without this the MINTED PLAN carries a REVOKE for every live grantee the models
|
|
203
208
|
// do not name — which is the artifact an operator actually applies.
|
|
204
209
|
governedRoles: declaredGovernedRoles,
|
|
210
|
+
extensions: declaredExtensions,
|
|
205
211
|
});
|
|
206
212
|
} catch (err: any) {
|
|
207
213
|
fail(`Mint refused: ${err.message}`);
|
|
@@ -76,6 +76,8 @@ export interface SyncOptions {
|
|
|
76
76
|
declared?: SourceObject[];
|
|
77
77
|
/** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
|
|
78
78
|
sequences?: SequenceDescriptor[];
|
|
79
|
+
/** Extensions the modules declare — emitted before anything that uses their types. */
|
|
80
|
+
extensions?: string[];
|
|
79
81
|
/** Roles the modules govern beyond the ones the models name — a grantee outside the set
|
|
80
82
|
* is exempted from revocation and enumerated instead (authz-reconcile's governedRoleSet). */
|
|
81
83
|
governedRoles?: string[];
|
|
@@ -123,12 +125,12 @@ export async function executeSync(
|
|
|
123
125
|
const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
124
126
|
const statements = generateMigrationSql(models, current, {
|
|
125
127
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
126
|
-
governedRoles: options.governedRoles,
|
|
128
|
+
governedRoles: options.governedRoles, extensions: options.extensions,
|
|
127
129
|
});
|
|
128
130
|
hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
|
|
129
131
|
|
|
130
132
|
const state = await applyStateAndVerify(runner, session, models, statements, current, liveAuthz, {
|
|
131
|
-
allowDrops: options.allowDrops, sequences: options.sequences,
|
|
133
|
+
allowDrops: options.allowDrops, sequences: options.sequences, extensions: options.extensions,
|
|
132
134
|
actor: options.actor, gitRef: options.gitRef, now: options.now,
|
|
133
135
|
});
|
|
134
136
|
hooks.onStateDone?.(state);
|
|
@@ -315,6 +317,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
315
317
|
{
|
|
316
318
|
declared: declaredDb?.objects,
|
|
317
319
|
sequences: declaredDb?.sequences,
|
|
320
|
+
extensions: declaredDb?.extensions,
|
|
318
321
|
renamedTables: declaredDb?.renamedTables,
|
|
319
322
|
allowDrops: flags['allow-drops'] === 'true',
|
|
320
323
|
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
@@ -34,6 +34,16 @@ export interface DeclaredDerived {
|
|
|
34
34
|
/** Roles the modules declare as governed beyond the ones the models name. A live grantee
|
|
35
35
|
* outside the governed set is exempted from reconciliation and ENUMERATED instead. */
|
|
36
36
|
governedRoles: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Postgres extensions the modules declare — the bootstrap the state layer needs before
|
|
39
|
+
* anything that uses their types. Only the from-scratch compiler consumed these; the DIFF
|
|
40
|
+
* path never saw them, so `db:generate --apply`, `db:sync` and the whole `db:plan`/
|
|
41
|
+
* `db:apply` stage lane emitted zero `CREATE EXTENSION`. A consumer measured the gap as
|
|
42
|
+
* exactly their nine extensions (471 statements vs db:check's 480 on one checkout), and
|
|
43
|
+
* it is not only a fresh-build problem: a deployed stage evolving onto a model that newly
|
|
44
|
+
* uses `hstore` or `postgis` fails on apply.
|
|
45
|
+
*/
|
|
46
|
+
extensions: string[];
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/**
|
|
@@ -178,6 +188,7 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
|
|
|
178
188
|
renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
|
|
179
189
|
models,
|
|
180
190
|
governedRoles: moduleGovernedRoles(modules),
|
|
191
|
+
extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
|
|
181
192
|
};
|
|
182
193
|
} catch (err) {
|
|
183
194
|
throw asModelComposeError(modelsPath, err);
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -123,6 +123,12 @@ export interface MintOptions {
|
|
|
123
123
|
* MINTED PLAN carries those revokes, which is where they would actually be applied.
|
|
124
124
|
*/
|
|
125
125
|
governedRoles?: string[];
|
|
126
|
+
/**
|
|
127
|
+
* Extensions the modules declare. A plan minted without them silently omits every
|
|
128
|
+
* `CREATE EXTENSION` the target lacks — so a stage evolving onto a model that newly uses
|
|
129
|
+
* an extension type fails at APPLY, with the plan having promised it would not.
|
|
130
|
+
*/
|
|
131
|
+
extensions?: string[];
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
/**
|
|
@@ -217,6 +223,7 @@ export function mintEdgePlan(
|
|
|
217
223
|
allowDrops: opts.allowDrops,
|
|
218
224
|
liveAuthz: contract,
|
|
219
225
|
governedRoles: opts.governedRoles,
|
|
226
|
+
extensions: opts.extensions,
|
|
220
227
|
});
|
|
221
228
|
const classified = classifyGeneratedStatements(statements);
|
|
222
229
|
if (classified.heldDrops.length > 0) {
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { dbDiffCommand } from './commands/db-diff.js';
|
|
|
19
19
|
import { dbPlanCommand } from './commands/db-plan.js';
|
|
20
20
|
import { dbApplyCommand } from './commands/db-apply.js';
|
|
21
21
|
import { dbCheckCommand } from './commands/db-check.js';
|
|
22
|
+
import { dbBuildCommand } from './commands/db-build.js';
|
|
22
23
|
import { dbApproversCommand } from './commands/db-approvers.js';
|
|
23
24
|
import { dbBackfillCommand } from './commands/db-backfill.js';
|
|
24
25
|
import { dbExecCommand } from './commands/db-exec.js';
|
|
@@ -208,6 +209,9 @@ async function main() {
|
|
|
208
209
|
case 'db:check':
|
|
209
210
|
await dbCheckCommand(flags);
|
|
210
211
|
break;
|
|
212
|
+
case 'db:build':
|
|
213
|
+
await dbBuildCommand(flags);
|
|
214
|
+
break;
|
|
211
215
|
case 'db:approvers':
|
|
212
216
|
await dbApproversCommand(flags);
|
|
213
217
|
break;
|
|
@@ -368,6 +372,7 @@ Usage:
|
|
|
368
372
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
369
373
|
everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line)
|
|
370
374
|
everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + an attested --snapshot-ref + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there
|
|
375
|
+
everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
|
|
371
376
|
everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
|
|
372
377
|
everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
|
|
373
378
|
everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
|
|
@@ -28,6 +28,9 @@ export interface GenerateOptions {
|
|
|
28
28
|
schema?: string;
|
|
29
29
|
/** Standalone sequences the modules declare (state layer — created before tables). */
|
|
30
30
|
sequences?: SequenceDescriptor[];
|
|
31
|
+
/** Postgres extensions the modules declare — emitted first, before anything using their
|
|
32
|
+
* types. Omitted = none declared; the live side's absent `extensions` means unknown. */
|
|
33
|
+
extensions?: string[];
|
|
31
34
|
/**
|
|
32
35
|
* Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
|
|
33
36
|
* database but absent from the Models. Default `false` — those are held back as
|
|
@@ -140,6 +143,23 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
140
143
|
// databases already have their schemas (their own migrations made them), so this is
|
|
141
144
|
// invisible in the brownfield loop. IF NOT EXISTS keeps an empty-but-existing schema
|
|
142
145
|
// (no tables for the live side to reveal it by) from failing the create.
|
|
146
|
+
// 0-pre. EXTENSIONS, before anything that could use their types.
|
|
147
|
+
//
|
|
148
|
+
// The from-scratch compiler emitted these from day one; this DIFF path never did, so the
|
|
149
|
+
// brownfield loop (db:sync, db:generate --apply) and the whole db:plan/db:apply stage lane
|
|
150
|
+
// were silently missing them. Two distinct failures, both measured on a consumer's
|
|
151
|
+
// checkout: a fresh build dies on `type "hstore" does not exist`, and a DEPLOYED stage
|
|
152
|
+
// evolving onto a model that newly uses an extension type fails the same way at apply.
|
|
153
|
+
//
|
|
154
|
+
// `current.extensions` is ABSENT when the caller never ran the extensions query — absent
|
|
155
|
+
// means unknown, not empty, so everything declared is emitted. `IF NOT EXISTS` makes that
|
|
156
|
+
// safe: the cost of not knowing is a no-op statement, never a failure.
|
|
157
|
+
const liveExtensions = current.extensions === undefined ? null : new Set(current.extensions);
|
|
158
|
+
const extensionPhase = [...new Set(opts.extensions ?? [])]
|
|
159
|
+
.filter((e) => liveExtensions === null || !liveExtensions.has(e))
|
|
160
|
+
.sort()
|
|
161
|
+
.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}"`);
|
|
162
|
+
|
|
143
163
|
const liveSchemas = new Set(current.tables.map((t) => t.table.split('.')[0]));
|
|
144
164
|
const schemaPhase = [...new Set(desired.tables.map((t) => t.table.split('.')[0]))]
|
|
145
165
|
.filter((s) => s !== 'public' && !liveSchemas.has(s))
|
|
@@ -269,7 +289,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
269
289
|
})
|
|
270
290
|
: [];
|
|
271
291
|
|
|
272
|
-
return [...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
292
|
+
return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
273
293
|
}
|
|
274
294
|
|
|
275
295
|
/** The marker drizzle migration files put between statements. */
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
|
|
23
23
|
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
|
|
24
24
|
import { nextvalSequence, type RenameMap } from './schema-diff.js';
|
|
25
|
+
import { normalizeDeparsedExpr } from './deparse-normal.js';
|
|
25
26
|
|
|
26
27
|
/** `authorId` -> `author_id`. SQL identifiers are snake_case. */
|
|
27
28
|
export function toSnakeCase(name: string): string {
|
|
@@ -46,7 +47,10 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
46
47
|
const cols = con.columns.map(toSnakeCase);
|
|
47
48
|
uniques.push({ name: `${model.table}_${cols.join('_')}_unique`, columns: cols });
|
|
48
49
|
} else if (con.kind === 'check') {
|
|
49
|
-
|
|
50
|
+
// Normalized like the live producer (schema-introspect): a model that transcribes a
|
|
51
|
+
// pulled predicate must compare equal to the database it came from, whichever of the
|
|
52
|
+
// two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
|
|
53
|
+
checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: normalizeDeparsedExpr(con.predicate) });
|
|
50
54
|
} else if (con.kind === 'index') {
|
|
51
55
|
// Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
|
|
52
56
|
// DESC, opclass) pass verbatim — they are already SQL (Brick E).
|
|
@@ -64,7 +68,7 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
64
68
|
name,
|
|
65
69
|
columns: cols,
|
|
66
70
|
unique: con.isUnique,
|
|
67
|
-
...(con.predicate ? { where: con.predicate } : {}),
|
|
71
|
+
...(con.predicate ? { where: normalizeDeparsedExpr(con.predicate) } : {}),
|
|
68
72
|
...(con.method ? { using: con.method } : {}),
|
|
69
73
|
...(con.includeColumns?.length ? { include: con.includeColumns.map(toSnakeCase) } : {}),
|
|
70
74
|
});
|
|
@@ -486,7 +490,9 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
486
490
|
const fieldChecks = entries.flatMap(([name, field]) => {
|
|
487
491
|
const col = toSnakeCase(name);
|
|
488
492
|
const pred = fieldCheckPredicate(col, field.spec);
|
|
489
|
-
|
|
493
|
+
// Through the same funnel as every other predicate: a `z.enum([...])` compiles to an
|
|
494
|
+
// IN-list, which is exactly the shape whose deparse is unstable across a dump/restore.
|
|
495
|
+
return pred ? [{ name: `${model.table}_${col}_check`, expr: normalizeDeparsedExpr(pred) }] : [];
|
|
490
496
|
});
|
|
491
497
|
const checks = [...fieldChecks, ...tableConstraints.checks];
|
|
492
498
|
|
|
@@ -110,7 +110,7 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
|
|
|
110
110
|
// it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
|
|
111
111
|
// policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
|
|
112
112
|
// exactly including the effective WITH CHECK.
|
|
113
|
-
export const FINGERPRINT_VERSION =
|
|
113
|
+
export const FINGERPRINT_VERSION = 10;
|
|
114
114
|
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
116
|
// Canonical form.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
|
|
17
17
|
import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
|
|
18
|
+
import { normalizeDeparsedExpr } from './deparse-normal.js';
|
|
18
19
|
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
// The data-layer snapshot — the structured shape both producers target.
|
|
@@ -409,9 +410,12 @@ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescrip
|
|
|
409
410
|
onUpdate: action('UPDATE'),
|
|
410
411
|
};
|
|
411
412
|
}
|
|
412
|
-
// check — keep the predicate text inside the outer CHECK ( ... )
|
|
413
|
+
// check — keep the predicate text inside the outer CHECK ( ... ), normalized.
|
|
414
|
+
// `pg_get_expr` output is NOT a fixed point under re-parse: dump a CHECK and restore it
|
|
415
|
+
// and the same server deparses it differently (measured 2026-08-10, PG13-18 alike). A
|
|
416
|
+
// restored clone would then fingerprint differently from the database it was cloned from.
|
|
413
417
|
const m = def.match(/^CHECK\s*\((.*)\)$/s);
|
|
414
|
-
return { table, name: row.name, kind: 'check', expr: m ? m[1].trim() : def };
|
|
418
|
+
return { table, name: row.name, kind: 'check', expr: normalizeDeparsedExpr(m ? m[1].trim() : def) };
|
|
415
419
|
}
|
|
416
420
|
|
|
417
421
|
// ---------------------------------------------------------------------------
|
|
@@ -491,6 +495,9 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
|
|
|
491
495
|
let where = whereMatch ? whereMatch[1].trim() : '';
|
|
492
496
|
// pg_get_indexdef wraps the predicate in one pair of parens — strip it.
|
|
493
497
|
if (where.startsWith('(') && parenGroup(where, 0) === where.slice(1, -1)) where = where.slice(1, -1).trim();
|
|
498
|
+
// Same fixed-point failure as CHECK predicates: a partial index's WHERE survives a
|
|
499
|
+
// dump/restore as different text for the same predicate. Normalize at the producer.
|
|
500
|
+
where = normalizeDeparsedExpr(where);
|
|
494
501
|
return {
|
|
495
502
|
columns,
|
|
496
503
|
unique: /^CREATE\s+UNIQUE\s+INDEX/i.test(definition),
|
|
@@ -504,7 +511,10 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
|
|
|
504
511
|
* a legacy row without one keeps the old plain-columns path, so fixtures stay valid.
|
|
505
512
|
* `is_unique`/`predicate`/`method` row fields take precedence over the parsed text. */
|
|
506
513
|
export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
|
|
507
|
-
|
|
514
|
+
// The row's own `predicate` column takes precedence over the parsed definition text, so
|
|
515
|
+
// it is the producer that must normalize — normalizing only the parse path left the
|
|
516
|
+
// predicate unstable across a dump/restore and the fingerprint with it.
|
|
517
|
+
const pred = row.predicate == null ? '' : normalizeDeparsedExpr(String(row.predicate));
|
|
508
518
|
const table = `${row.schema}.${row.table}`;
|
|
509
519
|
const base = { name: row.name, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) };
|
|
510
520
|
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -55,12 +55,23 @@ function strLiteral(value: string): string {
|
|
|
55
55
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts).
|
|
60
|
+
*
|
|
61
|
+
* `builder` is the drizzle column builder the value is a default FOR, because the accepted
|
|
62
|
+
* TYPE of the literal depends on it: drizzle's `numeric`/`decimal` take a STRING, since an
|
|
63
|
+
* arbitrary-precision default routed through a JS number is lossy by construction. A pulled
|
|
64
|
+
* model renders `field.numeric(4, 2).default(0.15)` — the model vocabulary accepting a number
|
|
65
|
+
* there is fine — and passing that straight through produced `.default(0.15)`, which fails
|
|
66
|
+
* `tsc --strict` with TS2345 and blocked a consumer from adopting the generated artifact.
|
|
67
|
+
*/
|
|
68
|
+
function defaultValueLiteral(value: unknown, builder?: string): string {
|
|
60
69
|
if (typeof value === 'string') return strLiteral(value);
|
|
61
70
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
62
71
|
if (value === null) return 'null';
|
|
63
|
-
if (typeof value === 'number')
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
return builder === 'numeric' || builder === 'decimal' ? strLiteral(String(value)) : String(value);
|
|
74
|
+
}
|
|
64
75
|
// Arrays / objects: a JSON literal is valid TS for jsonb defaults.
|
|
65
76
|
return JSON.stringify(value);
|
|
66
77
|
}
|
|
@@ -159,6 +170,7 @@ function columnModifiers(
|
|
|
159
170
|
spec: FieldSpec,
|
|
160
171
|
isComposite: boolean,
|
|
161
172
|
modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
|
|
173
|
+
ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string> } = {},
|
|
162
174
|
): string {
|
|
163
175
|
let s = '';
|
|
164
176
|
if (spec.isArray) s += '.array()';
|
|
@@ -167,7 +179,7 @@ function columnModifiers(
|
|
|
167
179
|
|
|
168
180
|
if (spec.defaultKind === 'now') s += '.defaultNow()';
|
|
169
181
|
else if (spec.defaultKind === 'random') s += '.defaultRandom()';
|
|
170
|
-
else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
|
|
182
|
+
else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default, ctx.builder)})`;
|
|
171
183
|
else if (spec.defaultKind === 'sql') s += `.default(sql.raw(${strLiteral(String(spec.default))}))`;
|
|
172
184
|
|
|
173
185
|
if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
|
|
@@ -181,7 +193,17 @@ function columnModifiers(
|
|
|
181
193
|
if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
|
|
182
194
|
if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
|
|
183
195
|
const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
|
|
184
|
-
s
|
|
196
|
+
// A SELF-referential FK needs drizzle's documented return-type annotation. Without it
|
|
197
|
+
// the table's own initializer references itself, TypeScript cannot infer it, and under
|
|
198
|
+
// `strict` it is TS7022 + TS7024 — which does not merely warn: the whole table
|
|
199
|
+
// degrades to `any`, so every query against it silently loses type safety in a project
|
|
200
|
+
// that otherwise builds. A reference to ANOTHER table infers fine and is left alone.
|
|
201
|
+
if (ctx.self && target === ctx.self) {
|
|
202
|
+
ctx.usedTypes?.add('AnyPgColumn');
|
|
203
|
+
s += `.references((): AnyPgColumn => ${entry.camel}.${parentPk}${optsText})`;
|
|
204
|
+
} else {
|
|
205
|
+
s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
|
|
206
|
+
}
|
|
185
207
|
}
|
|
186
208
|
// A reference to a model OUTSIDE the emitted set (a cross-schema FK) is omitted —
|
|
187
209
|
// the runtime + relations never needed it; the DB constraint stays unmanaged.
|
|
@@ -333,6 +355,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
333
355
|
|
|
334
356
|
// --- Track which pg-core builders + drizzle-orm symbols are used ----------
|
|
335
357
|
const pgCoreBuilders = new Set<string>();
|
|
358
|
+
// TYPE-only imports from pg-core (today: AnyPgColumn, for self-referential FKs). Kept apart
|
|
359
|
+
// from the value imports so the emitted `import type` line carries no runtime cost.
|
|
360
|
+
const pgCoreTypes = new Set<string>();
|
|
336
361
|
// Only when something actually lands in `public` — an all-non-public model set would
|
|
337
362
|
// otherwise import a builder it never calls.
|
|
338
363
|
if (models.some((m) => m.schema === 'public')) pgCoreBuilders.add('pgTable');
|
|
@@ -353,7 +378,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
353
378
|
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
354
379
|
pgCoreBuilders.add(pgBuilder);
|
|
355
380
|
if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
|
|
356
|
-
const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
|
|
381
|
+
const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes });
|
|
357
382
|
if (spec.isDeprecated) {
|
|
358
383
|
// The contract phase (decision 13): the column stays in the database and
|
|
359
384
|
// stays readable; the strikethrough warns new code away at author time.
|
|
@@ -457,7 +482,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
457
482
|
pgCoreBuilders.add(pgBuilder);
|
|
458
483
|
// Only .array()/.notNull() can appear — defineView/defineMaterializedView
|
|
459
484
|
// reject every table-structural modifier at define time.
|
|
460
|
-
const mods = columnModifiers(spec, true, modelsByDescriptor);
|
|
485
|
+
const mods = columnModifiers(spec, true, modelsByDescriptor, { builder: pgBuilder });
|
|
461
486
|
colLines.push(` ${key}: ${call}${mods},`);
|
|
462
487
|
}
|
|
463
488
|
const cols = `{\n${colLines.join('\n')}\n}`;
|
|
@@ -501,6 +526,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
501
526
|
|
|
502
527
|
const importLines: string[] = [];
|
|
503
528
|
importLines.push(`import { ${pgCoreNames.join(', ')} } from 'drizzle-orm/pg-core';`);
|
|
529
|
+
if (pgCoreTypes.size) {
|
|
530
|
+
importLines.push(`import type { ${[...pgCoreTypes].sort().join(', ')} } from 'drizzle-orm/pg-core';`);
|
|
531
|
+
}
|
|
504
532
|
|
|
505
533
|
const drizzleOrmNames: string[] = [];
|
|
506
534
|
if (anyRelations) drizzleOrmNames.push('relations');
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -347,6 +347,9 @@ export interface StateSyncOptions extends StateApplyOptions {
|
|
|
347
347
|
allowDrops?: boolean;
|
|
348
348
|
/** Standalone sequences — the verify re-diff must see the same declared state as the plan. */
|
|
349
349
|
sequences?: SequenceDescriptor[];
|
|
350
|
+
/** Extensions the modules declare — the re-generated stream must carry them too, or a
|
|
351
|
+
* verify-after re-plan disagrees with the plan that was applied. */
|
|
352
|
+
extensions?: string[];
|
|
350
353
|
}
|
|
351
354
|
|
|
352
355
|
export interface StateSyncOutcome {
|
|
@@ -406,7 +409,7 @@ export async function applyStateAndVerify(
|
|
|
406
409
|
}
|
|
407
410
|
|
|
408
411
|
const remaining = classifyGeneratedStatements(
|
|
409
|
-
generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences }),
|
|
412
|
+
generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences, extensions: options.extensions }),
|
|
410
413
|
).executable;
|
|
411
414
|
|
|
412
415
|
return {
|