@everystack/cli 0.4.31 → 0.4.33
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/aws.ts +1 -1
- package/src/cli/commands/db-backup.ts +187 -2
- package/src/cli/declared-derived.ts +5 -2
- package/src/cli/edge-plan.ts +10 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/migration-generate.ts +27 -7
- package/src/cli/schema-compile.ts +17 -0
- package/src/cli/schema-diff.ts +77 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.33",
|
|
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>",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
101
101
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
102
102
|
"@aws-sdk/client-ssm": "3.1053.0",
|
|
103
|
+
"@aws-sdk/lib-storage": "3.1053.0",
|
|
103
104
|
"@aws-sdk/client-sts": "3.1053.0",
|
|
104
105
|
"@aws-sdk/signature-v4a": "3.1048.0",
|
|
105
106
|
"glob": "13.0.6",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"structured-headers": "1.0.1",
|
|
108
109
|
"tsx": "4.21.0",
|
|
109
110
|
"typescript": "5.9.3",
|
|
110
|
-
"@everystack/model": "0.4.
|
|
111
|
+
"@everystack/model": "0.4.5"
|
|
111
112
|
},
|
|
112
113
|
"peerDependencies": {
|
|
113
114
|
"@everystack/server": ">=0.1.0",
|
package/src/cli/aws.ts
CHANGED
|
@@ -13,7 +13,7 @@ let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaCli
|
|
|
13
13
|
let kvsClient: InstanceType<typeof import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient> | null = null;
|
|
14
14
|
let rdsClient: InstanceType<typeof import('@aws-sdk/client-rds').RDSClient> | null = null;
|
|
15
15
|
|
|
16
|
-
async function getS3(region: string) {
|
|
16
|
+
export async function getS3(region: string) {
|
|
17
17
|
if (!s3Client) {
|
|
18
18
|
const { S3Client } = await importOptionalAws(
|
|
19
19
|
() => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'this command',
|
|
@@ -4,14 +4,152 @@
|
|
|
4
4
|
* db:backup/db:export run in the ephemeral Task lane (the ops Lambda holds no pg binaries); the CLI
|
|
5
5
|
* dispatches and polls. db:backups/db:restore/download are thin invokeAction wrappers. The image's
|
|
6
6
|
* version handshake is the compatibility gate — verify a deploy with `everystack task:probe`.
|
|
7
|
+
*
|
|
8
|
+
* db:backup has a SECOND venue for break-glass: `--database-url <local> --stage <target>` dumps a
|
|
9
|
+
* LOCAL database and uploads it into the target stage's backups bucket as a db:backup-shaped object
|
|
10
|
+
* (same key scheme + .meta.json + dump flags as a server backup). The standard credential-free
|
|
11
|
+
* `db:restore --from <id> --stage <target>` then lands it via the Task lane (pg_restore runs as the
|
|
12
|
+
* privileged `migrator` role, which can CREATE SCHEMA + own objects). The local dump is the only
|
|
13
|
+
* held connection; the upload rides the operator's AWS credentials — the same trust boundary as
|
|
14
|
+
* db:fork's cross-stage presign. This is the sanctioned local→stage full replace on a role-managed
|
|
15
|
+
* stage (raw pg_restore can't: the operator/api role can't CREATE SCHEMA, and only the master could).
|
|
7
16
|
*/
|
|
8
17
|
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { createGzip } from 'node:zlib';
|
|
20
|
+
import { pipeline } from 'node:stream/promises';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
9
22
|
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
10
|
-
import { invokeAction, presignGet } from '../aws.js';
|
|
11
|
-
import {
|
|
23
|
+
import { invokeAction, presignGet, getS3 } from '../aws.js';
|
|
24
|
+
import {
|
|
25
|
+
parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard,
|
|
26
|
+
backupKey, backupId, metaKey, utcStamp,
|
|
27
|
+
} from '../backup.js';
|
|
28
|
+
import { pgEnvFromUrl } from './db.js';
|
|
12
29
|
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
13
30
|
import { step, success, fail, info, warn } from '../output.js';
|
|
14
31
|
|
|
32
|
+
/** Which database db:backup dumps. LOCAL requires a target --stage (its bucket receives the upload
|
|
33
|
+
* AND names the backup id). Note this DIVERGES from db:export, where --database-url and --stage are
|
|
34
|
+
* mutually-exclusive venues: here the local venue COMBINES them (dump local → upload to the stage). */
|
|
35
|
+
export type BackupVenue =
|
|
36
|
+
| { kind: 'local'; url: string; stage: string }
|
|
37
|
+
| { kind: 'stage'; stage: string | undefined };
|
|
38
|
+
|
|
39
|
+
export function resolveBackupVenue(flags: Record<string, string>): BackupVenue {
|
|
40
|
+
const url = flags['database-url'];
|
|
41
|
+
if (url) {
|
|
42
|
+
if (!flags.stage) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'db:backup --database-url <local> also needs --stage <target> — the local dump uploads into '
|
|
45
|
+
+ "that stage's backups bucket, and the stage names the backup id `db:restore --from` consumes.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return { kind: 'local', url, stage: flags.stage };
|
|
49
|
+
}
|
|
50
|
+
return { kind: 'stage', stage: flags.stage };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Full-DB streaming pg_dump args — IDENTICAL to the server's db:backup dump (packages/server
|
|
55
|
+
* backup.ts pgDumpArgs()), minus `-f`: streamed to stdout so it pipes into gzip → S3. Excludes the
|
|
56
|
+
* everystack ops schema (task_log/schema_log/etc — the TARGET's own audit history, not app data;
|
|
57
|
+
* restoring it would rewind that history AND makes pg_restore --clean fail on DROP SCHEMA everystack).
|
|
58
|
+
*/
|
|
59
|
+
export function pgDumpFullArgs(): string[] {
|
|
60
|
+
return ['-Fc', '--no-owner', '--no-privileges', '--no-comments', '--exclude-schema=everystack'];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The .meta.json sidecar — byte-shape identical to the server's runBackup meta (backup-run.ts:156),
|
|
64
|
+
* so db:backups list and the destructive-apply gate read a local upload exactly like a stage backup.
|
|
65
|
+
* `bytes` is undefined-safe (JSON.stringify drops it) exactly as the server leaves it on a failed
|
|
66
|
+
* HeadObject — never coerced to 0. */
|
|
67
|
+
export function localBackupMeta(opts: {
|
|
68
|
+
id: string;
|
|
69
|
+
stage: string;
|
|
70
|
+
createdAt: Date;
|
|
71
|
+
bytes?: number;
|
|
72
|
+
key: string;
|
|
73
|
+
}): Record<string, unknown> {
|
|
74
|
+
return {
|
|
75
|
+
id: opts.id,
|
|
76
|
+
stage: opts.stage,
|
|
77
|
+
createdAt: opts.createdAt.toISOString(),
|
|
78
|
+
bytes: opts.bytes,
|
|
79
|
+
key: opts.key,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stream a local database into a stage's backups bucket as a db:backup-shaped object. Mirrors the
|
|
85
|
+
* server's runBackup streaming AND its load-bearing correctness rule: a completed S3 multipart upload
|
|
86
|
+
* does NOT mean pg_dump succeeded (pg_dump can exit non-zero mid-stream while the upload "completes"),
|
|
87
|
+
* so we await the child exit code SEPARATELY and, on any failure, abort the upload and delete the
|
|
88
|
+
* partial object. A backup tool that ships a truncated dump is worse than none.
|
|
89
|
+
*/
|
|
90
|
+
export async function runLocalBackupUpload(deps: {
|
|
91
|
+
region: string;
|
|
92
|
+
bucket: string;
|
|
93
|
+
url: string;
|
|
94
|
+
stage: string;
|
|
95
|
+
}): Promise<{ id: string; key: string; bytes?: number }> {
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const stamp = utcStamp(now);
|
|
98
|
+
const shortId = randomBytes(3).toString('hex');
|
|
99
|
+
const key = backupKey(deps.stage, stamp, shortId);
|
|
100
|
+
const id = backupId(deps.stage, stamp, shortId);
|
|
101
|
+
const mkey = metaKey(key);
|
|
102
|
+
|
|
103
|
+
const env = { ...process.env, ...pgEnvFromUrl(deps.url) };
|
|
104
|
+
const s3 = await getS3(deps.region);
|
|
105
|
+
const { Upload } = await import('@aws-sdk/lib-storage');
|
|
106
|
+
const { DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
|
|
107
|
+
|
|
108
|
+
const child = spawn('pg_dump', pgDumpFullArgs(), { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
109
|
+
let stderr = '';
|
|
110
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
111
|
+
const childExit = new Promise<void>((resolve, reject) => {
|
|
112
|
+
child.on('error', reject);
|
|
113
|
+
child.on('close', (code) =>
|
|
114
|
+
code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.trim()}`)));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const gzip = createGzip();
|
|
118
|
+
const upload = new Upload({
|
|
119
|
+
client: s3,
|
|
120
|
+
params: {
|
|
121
|
+
Bucket: deps.bucket, Key: key, Body: gzip,
|
|
122
|
+
ContentType: 'application/gzip', ServerSideEncryption: 'AES256',
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await Promise.all([
|
|
128
|
+
pipeline(child.stdout!, gzip), // pg_dump → gzip; errors propagate, streams destroyed
|
|
129
|
+
upload.done(), // gzip → S3 multipart
|
|
130
|
+
childExit, // the guard: non-zero pg_dump rejects the whole thing
|
|
131
|
+
]);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
try { await upload.abort(); } catch { /* may already have completed */ }
|
|
134
|
+
await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: key })).catch(() => {});
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let bytes: number | undefined;
|
|
139
|
+
try {
|
|
140
|
+
const head = await s3.send(new HeadObjectCommand({ Bucket: deps.bucket, Key: key }));
|
|
141
|
+
bytes = head.ContentLength;
|
|
142
|
+
} catch { /* size is best-effort */ }
|
|
143
|
+
|
|
144
|
+
await s3.send(new PutObjectCommand({
|
|
145
|
+
Bucket: deps.bucket, Key: mkey,
|
|
146
|
+
Body: JSON.stringify(localBackupMeta({ id, stage: deps.stage, createdAt: now, bytes, key })),
|
|
147
|
+
ContentType: 'application/json', ServerSideEncryption: 'AES256',
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
return { id, key, bytes };
|
|
151
|
+
}
|
|
152
|
+
|
|
15
153
|
function fmtBytes(n?: number): string {
|
|
16
154
|
if (n == null) return '-';
|
|
17
155
|
if (n < 1024) return `${n} B`;
|
|
@@ -27,6 +165,53 @@ function fmtBytes(n?: number): string {
|
|
|
27
165
|
* the compatibility gate (a too-old image fails the run loudly — no separate layer pre-flight).
|
|
28
166
|
*/
|
|
29
167
|
export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
|
|
168
|
+
let venue: BackupVenue;
|
|
169
|
+
try {
|
|
170
|
+
venue = resolveBackupVenue(flags);
|
|
171
|
+
} catch (err: any) {
|
|
172
|
+
fail(err.message);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Local venue: dump a reachable database here and upload it into the target stage's backups bucket
|
|
177
|
+
// as a db:backup-shaped object, so `db:restore --from <id> --stage <target>` lands it credential-free.
|
|
178
|
+
if (venue.kind === 'local') {
|
|
179
|
+
step('Resolving deployed config...');
|
|
180
|
+
let config: CliConfig;
|
|
181
|
+
try {
|
|
182
|
+
config = await resolveConfig(venue.stage);
|
|
183
|
+
} catch (err: any) {
|
|
184
|
+
fail(err.message);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
if (!config.backupsBucket) {
|
|
188
|
+
fail('No backupsBucket in the deployed config. Add `backupsBucket: backups.name` to sst.config outputs and redeploy.');
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
info(`Region: ${config.region}, Bucket: ${config.backupsBucket}`);
|
|
192
|
+
// Version gate for the local venue: unlike the stage venue (whose pg binaries are the Task image),
|
|
193
|
+
// this dumps with the OPERATOR's pg_dump. pg_restore refuses an archive from a NEWER pg_dump major,
|
|
194
|
+
// so a mismatch surfaces only at restore time ("unsupported version in file header") — loud and
|
|
195
|
+
// lossless (it fails before --clean drops anything), but discovered exactly when you need the
|
|
196
|
+
// backup. Surface the local major up front so the operator can match it to the target's PG major.
|
|
197
|
+
try {
|
|
198
|
+
const { execFileSync } = await import('node:child_process');
|
|
199
|
+
const v = execFileSync('pg_dump', ['--version']).toString().trim();
|
|
200
|
+
info(`Local ${v} — must be ≤ the target stage's PostgreSQL major, or db:restore will reject the dump.`);
|
|
201
|
+
} catch { /* a missing pg_dump surfaces loudly in the dump step below */ }
|
|
202
|
+
step(`Running pg_dump (full, --exclude-schema=everystack) against --database-url → S3 (this may take a while for large databases)...`);
|
|
203
|
+
try {
|
|
204
|
+
const res = await runLocalBackupUpload({
|
|
205
|
+
region: config.region, bucket: config.backupsBucket, url: venue.url, stage: venue.stage,
|
|
206
|
+
});
|
|
207
|
+
success(`Backup ${res.id} (${fmtBytes(res.bytes)}). Restore with: everystack db:restore --from ${res.id} --stage ${venue.stage} --confirm`);
|
|
208
|
+
} catch (err: any) {
|
|
209
|
+
fail(`Backup failed: ${err.message}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
30
215
|
step('Resolving deployed config...');
|
|
31
216
|
let config: CliConfig;
|
|
32
217
|
try {
|
|
@@ -13,7 +13,7 @@ import fs from 'node:fs/promises';
|
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
14
|
import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
|
|
15
15
|
import { compileDerived } from './derived-compile.js';
|
|
16
|
-
import { compileTableRenames } from './schema-compile.js';
|
|
16
|
+
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
17
17
|
import type { SourceObject } from './derived-source.js';
|
|
18
18
|
import { resolveModelsPath } from './models-path.js';
|
|
19
19
|
import { asModelComposeError } from './ops-advice.js';
|
|
@@ -168,7 +168,10 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
|
|
|
168
168
|
objects: compileDerived(models, derived),
|
|
169
169
|
derived,
|
|
170
170
|
sequences: modules.flatMap((m) => m.sequences),
|
|
171
|
-
|
|
171
|
+
// A table's qualified identity changes under BOTH a rename (new name) and a move
|
|
172
|
+
// (new schema); PostgreSQL carries triggers through ALTER TABLE ... RENAME and
|
|
173
|
+
// ... SET SCHEMA alike, so the trigger-provenance migration is identical — feed both.
|
|
174
|
+
renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
|
|
172
175
|
models,
|
|
173
176
|
};
|
|
174
177
|
} catch (err) {
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
|
|
|
32
32
|
import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
|
|
33
33
|
import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
|
|
34
34
|
import { compileDeclaredState } from './declared-diff.js';
|
|
35
|
-
import { compileTableRenames } from './schema-compile.js';
|
|
35
|
+
import { compileTableRenames, compileTableMoves } from './schema-compile.js';
|
|
36
36
|
|
|
37
37
|
export const PLAN_VERSION = 2;
|
|
38
38
|
|
|
@@ -101,8 +101,16 @@ export function predictLiveFingerprint(
|
|
|
101
101
|
.filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
|
|
102
102
|
.map(([, from]) => from),
|
|
103
103
|
);
|
|
104
|
+
// A move's source is consumed by the move (SET SCHEMA), not left behind — so it does NOT
|
|
105
|
+
// ride through into the predicted endpoint. Without this the prediction counts the
|
|
106
|
+
// moved-away table and every move plan mismatches its own verify-after (F3).
|
|
107
|
+
const pendingMoveSources = new Set(
|
|
108
|
+
Object.entries(compileTableMoves(models, { schema: opts.schema }))
|
|
109
|
+
.filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
|
|
110
|
+
.map(([, from]) => from),
|
|
111
|
+
);
|
|
104
112
|
const ridesThrough = (table: string): boolean =>
|
|
105
|
-
!declaredTables.has(table) && !pendingRenameSources.has(table);
|
|
113
|
+
!declaredTables.has(table) && !pendingRenameSources.has(table) && !pendingMoveSources.has(table);
|
|
106
114
|
const declaredEnumList = declared.snapshot.enums ?? [];
|
|
107
115
|
const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
|
|
108
116
|
const mergedSnapshot: SchemaSnapshot = {
|
package/src/cli/index.ts
CHANGED
|
@@ -368,7 +368,7 @@ Usage:
|
|
|
368
368
|
everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
|
|
369
369
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
370
370
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
371
|
-
everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id)
|
|
371
|
+
everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)
|
|
372
372
|
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
373
373
|
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
374
374
|
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
|
|
19
19
|
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
20
20
|
import type { AuthzContract } from './authz-contract.js';
|
|
21
|
-
import { compileTableSchema, compileRenames, compileTableRenames, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
|
|
21
|
+
import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
|
|
22
22
|
import { compileTableContract } from './authz-compile.js';
|
|
23
23
|
import { emitReconcileSql } from './authz-reconcile.js';
|
|
24
24
|
import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
@@ -84,11 +84,16 @@ function holdDrop(sql: string): string {
|
|
|
84
84
|
export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
85
85
|
const schema = opts.schema ?? 'public';
|
|
86
86
|
const declared = new Set(models.map((m) => `${schema}.${m.table}`));
|
|
87
|
-
// A pending table rename's source is ours — declared under its new
|
|
87
|
+
// A pending table rename's OR move's source is ours — declared under its new (qualified)
|
|
88
|
+
// name; without this it reads as an undeclared orphan (F1) and the move/rename silently
|
|
89
|
+
// degrades to CREATE + leave-behind, the exact bug the markers exist to prevent.
|
|
88
90
|
const currentNames = new Set(current.tables.map((t) => t.table));
|
|
89
91
|
for (const [to, from] of Object.entries(compileTableRenames(models, { schema }))) {
|
|
90
92
|
if (!currentNames.has(to)) declared.add(from);
|
|
91
93
|
}
|
|
94
|
+
for (const [to, from] of Object.entries(compileTableMoves(models, { schema }))) {
|
|
95
|
+
if (!currentNames.has(to)) declared.add(from);
|
|
96
|
+
}
|
|
92
97
|
return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
|
|
93
98
|
}
|
|
94
99
|
|
|
@@ -131,20 +136,28 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
131
136
|
.map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}"`);
|
|
132
137
|
|
|
133
138
|
const tableRenames = compileTableRenames(models, { schema });
|
|
139
|
+
const tableMoves = compileTableMoves(models, { schema });
|
|
134
140
|
const currentNames = new Set(current.tables.map((t) => t.table));
|
|
135
141
|
const pendingRenameSources = new Set(
|
|
136
142
|
Object.entries(tableRenames)
|
|
137
143
|
.filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
|
|
138
144
|
.map(([, from]) => from),
|
|
139
145
|
);
|
|
146
|
+
// A move's source (old schema, same name) must survive the declared-scope filter too —
|
|
147
|
+
// else the diff's pre-pass never sees it and the move degrades to CREATE + orphan (F1).
|
|
148
|
+
const pendingMoveSources = new Set(
|
|
149
|
+
Object.entries(tableMoves)
|
|
150
|
+
.filter(([to, from]) => !currentNames.has(to) && currentNames.has(from))
|
|
151
|
+
.map(([, from]) => from),
|
|
152
|
+
);
|
|
140
153
|
const scopedCurrent: SchemaSnapshot = opts.scope === 'full'
|
|
141
154
|
? { tables: current.tables, enums: current.enums ?? [] }
|
|
142
155
|
: {
|
|
143
|
-
tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table)),
|
|
156
|
+
tables: current.tables.filter((t) => declaredTables.has(t.table) || pendingRenameSources.has(t.table) || pendingMoveSources.has(t.table)),
|
|
144
157
|
enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
|
|
145
158
|
};
|
|
146
159
|
const renames = compileRenames(models, { schema });
|
|
147
|
-
const changes = diffSchema(desired, scopedCurrent, renames, tableRenames);
|
|
160
|
+
const changes = diffSchema(desired, scopedCurrent, renames, tableRenames, tableMoves);
|
|
148
161
|
|
|
149
162
|
const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
|
|
150
163
|
const emitted = emitSchemaSql(changes, {
|
|
@@ -174,11 +187,18 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
174
187
|
// data DDL just created. Emitted only when the live contract is supplied; the Models'
|
|
175
188
|
// `abilities` compile to the same contract shape introspection reads, so the two diff.
|
|
176
189
|
// Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
|
|
177
|
-
|
|
178
|
-
|
|
190
|
+
// A moved table carries its policies/grants with it through SET SCHEMA (OID-attached),
|
|
191
|
+
// so the live authz keyed under the OLD schema must be read under the NEW name — else
|
|
192
|
+
// emitReconcileSql finds no live entry and emits a bare CREATE POLICY that collides
|
|
193
|
+
// post-move and rolls back the whole apply (F2). Same rewrite the rename path relies on.
|
|
194
|
+
const pendingByFrom = new Map([
|
|
195
|
+
...Object.entries(tableRenames)
|
|
179
196
|
.filter(([, from]) => pendingRenameSources.has(from))
|
|
180
197
|
.map(([to, from]) => [from, to] as const),
|
|
181
|
-
|
|
198
|
+
...Object.entries(tableMoves)
|
|
199
|
+
.filter(([, from]) => pendingMoveSources.has(from))
|
|
200
|
+
.map(([to, from]) => [from, to] as const),
|
|
201
|
+
]);
|
|
182
202
|
const liveAuthzRenamed = opts.liveAuthz && pendingByFrom.size > 0
|
|
183
203
|
? {
|
|
184
204
|
...opts.liveAuthz,
|
|
@@ -575,3 +575,20 @@ export function compileTableRenames(models: ModelDescriptor[], opts: { schema?:
|
|
|
575
575
|
}
|
|
576
576
|
return map;
|
|
577
577
|
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Table-level MOVE intent: qualified new name → qualified old name, SAME table name in a
|
|
581
|
+
* DIFFERENT schema (the cross-schema twin of `compileTableRenames`). Produced off the
|
|
582
|
+
* Models' `movedFrom` option, consumed by `diffSchema`'s pre-pass, which turns a
|
|
583
|
+
* create-in-new-schema + undeclared orphan into one `ALTER TABLE … SET SCHEMA …`.
|
|
584
|
+
*/
|
|
585
|
+
export function compileTableMoves(models: ModelDescriptor[], opts: { schema?: string } = {}): Record<string, string> {
|
|
586
|
+
const map: Record<string, string> = {};
|
|
587
|
+
for (const model of models) {
|
|
588
|
+
if (!model.movedFrom) continue;
|
|
589
|
+
const newSchema = model.schema ?? opts.schema ?? 'public';
|
|
590
|
+
// Same table name; only the schema changes. from = old schema, to = new schema.
|
|
591
|
+
map[`${newSchema}.${model.table}`] = `${model.movedFrom}.${model.table}`;
|
|
592
|
+
}
|
|
593
|
+
return map;
|
|
594
|
+
}
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -72,6 +72,12 @@ export type SchemaChange =
|
|
|
72
72
|
| { kind: 'renameTableSatisfied'; table: string; from: string }
|
|
73
73
|
/** A table marker pointing at a table the database doesn't have — fell back to CREATE, a notice. */
|
|
74
74
|
| { kind: 'renameTableSourceMissing'; table: string; from: string }
|
|
75
|
+
/** A table-level `movedFrom` satisfied: old-schema table present, new absent → one SET SCHEMA, data preserved. */
|
|
76
|
+
| { kind: 'moveTable'; from: string; to: string }
|
|
77
|
+
/** A move marker whose move is already applied (table already in the new schema) — inert, a notice. */
|
|
78
|
+
| { kind: 'moveTableSatisfied'; table: string; from: string }
|
|
79
|
+
/** A move marker pointing at a table absent in BOTH schemas — fell back to CREATE, a notice. */
|
|
80
|
+
| { kind: 'moveTableSourceMissing'; table: string; from: string }
|
|
75
81
|
/** A new enum type → `CREATE TYPE … AS ENUM (…)`, emitted before the tables that use it. */
|
|
76
82
|
| { kind: 'createType'; name: string; values: string[] }
|
|
77
83
|
/** A value appended to an existing enum → `ALTER TYPE … ADD VALUE` (carries a -- WARNING). */
|
|
@@ -105,6 +111,15 @@ export type RenameMap = Record<string, Record<string, string>>;
|
|
|
105
111
|
*/
|
|
106
112
|
export type TableRenameMap = Record<string, string>;
|
|
107
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Table-level MOVE intent: qualified new name → qualified old name (SAME table name, a
|
|
116
|
+
* DIFFERENT schema). Produced by `compileTableMoves` off the Models' `movedFrom` option;
|
|
117
|
+
* consumed by `diffSchema`'s pre-pass, which rewrites the current snapshot (old-schema
|
|
118
|
+
* table seen under its new qualified name, and every referencing FK re-pointed) so a
|
|
119
|
+
* create-in-new-schema + orphan collapses to one `ALTER TABLE … SET SCHEMA …`.
|
|
120
|
+
*/
|
|
121
|
+
export type TableMoveMap = Record<string, string>;
|
|
122
|
+
|
|
108
123
|
// ---------------------------------------------------------------------------
|
|
109
124
|
// Normalization — so semantically-equal expressions don't read as drift.
|
|
110
125
|
// ---------------------------------------------------------------------------
|
|
@@ -290,8 +305,9 @@ export function normalizeCheck(expr: string): string {
|
|
|
290
305
|
* replaced constraint never clashes with its old self and a column is never dropped out
|
|
291
306
|
* from under a constraint that still references it.
|
|
292
307
|
*/
|
|
293
|
-
export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}, tableRenames: TableRenameMap = {}): SchemaChange[] {
|
|
308
|
+
export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}, tableRenames: TableRenameMap = {}, tableMoves: TableMoveMap = {}): SchemaChange[] {
|
|
294
309
|
const creates: SchemaChange[] = [];
|
|
310
|
+
const moveTables: SchemaChange[] = [];
|
|
295
311
|
const renameTables: SchemaChange[] = [];
|
|
296
312
|
const renameColumns: SchemaChange[] = [];
|
|
297
313
|
const addColumns: SchemaChange[] = [];
|
|
@@ -307,6 +323,55 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
307
323
|
const currentByName = new Map(current.tables.map((t) => [t.table, t]));
|
|
308
324
|
const desiredNames = new Set(desired.tables.map((t) => t.table));
|
|
309
325
|
|
|
326
|
+
// Table-move pre-pass: honor the Models' cross-schema `movedFrom` before any matching
|
|
327
|
+
// (runs before the rename pre-pass; a table cannot carry both markers — defineModel
|
|
328
|
+
// forbids it). SET SCHEMA carries the table's data, indexes, constraints, policies,
|
|
329
|
+
// grants, triggers and owned sequences with it (OID-attached), so a satisfied move
|
|
330
|
+
// rewrites the current snapshot: the old-schema table is seen under its new qualified
|
|
331
|
+
// name (every downstream column/constraint/index diff lands on it, and the old name
|
|
332
|
+
// never reaches the dropTables sweep), AND every referencing FK is re-pointed old→new.
|
|
333
|
+
// new-name-present → inert notice; source absent in both schemas → CREATE path + notice.
|
|
334
|
+
for (const [to, from] of Object.entries(tableMoves)) {
|
|
335
|
+
if (!desiredNames.has(to)) continue; // the marker's model isn't in this diff
|
|
336
|
+
if (currentByName.has(to)) {
|
|
337
|
+
notices.push({ kind: 'moveTableSatisfied', table: to, from });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const source = currentByName.get(from);
|
|
341
|
+
if (!source) {
|
|
342
|
+
notices.push({ kind: 'moveTableSourceMissing', table: to, from });
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
moveTables.push({ kind: 'moveTable', from, to });
|
|
346
|
+
currentByName.delete(from);
|
|
347
|
+
// The table's OWNED sequences ride SET SCHEMA too, so a serial default that qualified
|
|
348
|
+
// the old schema (`nextval('stats.foo_id_seq')`) reads `nextval('curated.foo_id_seq')`
|
|
349
|
+
// after the move — rewrite it on the copied source so it does not churn as a spurious
|
|
350
|
+
// SET DEFAULT (F8). Only the moved table's OWN sequence-qualified defaults are touched.
|
|
351
|
+
const fromSchema = from.split('.')[0];
|
|
352
|
+
const toSchema = to.split('.')[0];
|
|
353
|
+
// Literal replace (split/join), not a RegExp — a schema name can be a quoted identifier
|
|
354
|
+
// carrying regex metacharacters, which `new RegExp` would misfire or throw on.
|
|
355
|
+
const requalifyDefault = (d: string | null): string | null =>
|
|
356
|
+
d == null ? d : d.split(`nextval('${fromSchema}.`).join(`nextval('${toSchema}.`);
|
|
357
|
+
currentByName.set(to, {
|
|
358
|
+
...source,
|
|
359
|
+
table: to,
|
|
360
|
+
columns: source.columns.map((col) => (col.default != null ? { ...col, default: requalifyDefault(col.default) } : col)),
|
|
361
|
+
});
|
|
362
|
+
// F4: re-point every referencing FK (including the moved table's own self-FKs) from
|
|
363
|
+
// the old qualified name to the new, across the WHOLE current snapshot — so a
|
|
364
|
+
// referencing table's FK content-key matches the desired side and Postgres's
|
|
365
|
+
// carried-through FK is not needlessly dropped and re-added.
|
|
366
|
+
for (const [name, t] of currentByName) {
|
|
367
|
+
if (!t.foreignKeys.some((fk) => fk.refTable === from)) continue;
|
|
368
|
+
currentByName.set(name, {
|
|
369
|
+
...t,
|
|
370
|
+
foreignKeys: t.foreignKeys.map((fk) => (fk.refTable === from ? { ...fk, refTable: to } : fk)),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
310
375
|
// Table-rename pre-pass: honor the Models' table-level `renamedFrom` before
|
|
311
376
|
// any matching. A satisfied rename rewrites the current snapshot — the old
|
|
312
377
|
// table is seen under its new name — so every downstream diff (columns,
|
|
@@ -404,6 +469,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
404
469
|
// the other removals; notices are comments — emitted last, after the DDL.
|
|
405
470
|
return [
|
|
406
471
|
...e.creates, ...e.adds, ...sequenceCreates,
|
|
472
|
+
...moveTables,
|
|
407
473
|
...renameTables,
|
|
408
474
|
...creates, ...renameColumns, ...addColumns, ...alters, ...trailingDefaults,
|
|
409
475
|
...dropConstraints, ...addConstraints, ...createIndexes,
|
|
@@ -753,6 +819,16 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
753
819
|
case 'renameTable':
|
|
754
820
|
// RENAME TO takes a bare name — the table stays in its schema.
|
|
755
821
|
return `ALTER TABLE ${change.from} RENAME TO ${quote(change.to.split('.').pop()!)};`;
|
|
822
|
+
case 'moveTable':
|
|
823
|
+
// SET SCHEMA takes a bare schema name; the table keeps its name and carries its data,
|
|
824
|
+
// indexes, constraints, policies, grants, triggers and owned sequences with it. The
|
|
825
|
+
// WARNING (not NOTICE — this statement is executable; NOTICE is the reserved pure-comment
|
|
826
|
+
// prefix) flags the consumer-breakage surface a move can't see (see F7).
|
|
827
|
+
return `-- WARNING: ${change.from} moves to schema ${change.to.split('.')[0]} — unqualified readers resolve by search_path (verify ordering), and qualified references (views, functions, app queries naming ${change.from}) must be updated + reconciled.\nALTER TABLE ${change.from} SET SCHEMA ${quote(change.to.split('.')[0])};`;
|
|
828
|
+
case 'moveTableSatisfied':
|
|
829
|
+
return `-- NOTICE: the movedFrom marker on table ${change.table} is satisfied — the table already exists in its declared schema, ASSUMED to be the completed move from "${change.from.split('.')[0]}" (a pre-existing unrelated table of the same name would read the same); the marker is now inert and can be removed.`;
|
|
830
|
+
case 'moveTableSourceMissing':
|
|
831
|
+
return `-- NOTICE: table ${change.table} declares movedFrom "${change.from.split('.')[0]}", but the database has it in neither schema — created fresh; remove the marker once every environment is past it.`;
|
|
756
832
|
case 'renameTableSatisfied':
|
|
757
833
|
return `-- NOTICE: the renamedFrom marker on table ${change.table} is satisfied — the rename from "${change.from}" is applied, the marker is now inert and can be removed.`;
|
|
758
834
|
case 'renameTableSourceMissing':
|