@rebasepro/server-postgres 0.10.1-canary.a54c057 → 0.10.1-canary.b1e3dbf
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/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1314 -159
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +114 -6
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +32 -3
- package/src/auth/ensure-tables.ts +70 -56
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/index.ts +4 -0
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +383 -14
|
@@ -17,8 +17,11 @@ import {
|
|
|
17
17
|
BackupDestination,
|
|
18
18
|
buildBackupFilename,
|
|
19
19
|
buildPgDumpArgs,
|
|
20
|
+
buildPgDumpallGlobalsArgs,
|
|
20
21
|
buildPgRestoreArgs,
|
|
22
|
+
buildPgRestoreListArgs,
|
|
21
23
|
checkToolServerCompatibility,
|
|
24
|
+
globalsFileForDump,
|
|
22
25
|
joinStorageKey,
|
|
23
26
|
parsePgToolMajor,
|
|
24
27
|
parseBackupTimestamp,
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
VersionCompatibility
|
|
27
30
|
} from "./pg-tools";
|
|
28
31
|
import { BackupObject, RetentionOptions, selectBackupsToPrune } from "./retention";
|
|
32
|
+
import { applyGlobalsWith, pruneWith } from "./backup-logic";
|
|
29
33
|
|
|
30
34
|
export class BackupToolError extends Error {
|
|
31
35
|
constructor(message: string, readonly hint?: string) {
|
|
@@ -34,13 +38,16 @@ export class BackupToolError extends Error {
|
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
/** Locate `pg_dump` / `pg_restore`, honouring an env override. */
|
|
41
|
+
/** Locate `pg_dump` / `pg_restore` / `pg_dumpall`, honouring an env override. */
|
|
38
42
|
export function resolvePgBinary(
|
|
39
|
-
tool: "pg_dump" | "pg_restore",
|
|
43
|
+
tool: "pg_dump" | "pg_restore" | "pg_dumpall",
|
|
40
44
|
env: Record<string, string | undefined> = process.env
|
|
41
45
|
): string | null {
|
|
42
|
-
const
|
|
43
|
-
|
|
46
|
+
const overrideVar =
|
|
47
|
+
tool === "pg_dump" ? env.PG_DUMP_PATH
|
|
48
|
+
: tool === "pg_restore" ? env.PG_RESTORE_PATH
|
|
49
|
+
: env.PG_DUMPALL_PATH;
|
|
50
|
+
if (overrideVar && fs.existsSync(overrideVar)) return overrideVar;
|
|
44
51
|
return resolveLocalBin(tool);
|
|
45
52
|
}
|
|
46
53
|
|
|
@@ -105,12 +112,23 @@ export interface BackupResult {
|
|
|
105
112
|
localFile: string;
|
|
106
113
|
fileName: string;
|
|
107
114
|
sizeBytes: number;
|
|
115
|
+
/**
|
|
116
|
+
* Absolute path of the `.globals.sql` sidecar holding cluster-wide roles
|
|
117
|
+
* (present unless globals capture was disabled or unavailable).
|
|
118
|
+
*/
|
|
119
|
+
globalsFile?: string;
|
|
120
|
+
globalsSizeBytes?: number;
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
/**
|
|
111
124
|
* Produce a custom-format dump on local disk. When `outDir` is omitted the
|
|
112
125
|
* file is written to the OS temp directory (used by the upload path, which
|
|
113
126
|
* cleans it up afterwards).
|
|
127
|
+
*
|
|
128
|
+
* Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
|
|
129
|
+
* `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
|
|
130
|
+
* depend on can be recreated on restore. Set `includeGlobals: false` to skip
|
|
131
|
+
* it (e.g. when the caller has no privilege to read cluster globals).
|
|
114
132
|
*/
|
|
115
133
|
export async function createDump(opts: {
|
|
116
134
|
connectionString: string;
|
|
@@ -120,6 +138,7 @@ export async function createDump(opts: {
|
|
|
120
138
|
excludeSchemas?: string[];
|
|
121
139
|
noOwner?: boolean;
|
|
122
140
|
inheritStdio?: boolean;
|
|
141
|
+
includeGlobals?: boolean;
|
|
123
142
|
env?: Record<string, string | undefined>;
|
|
124
143
|
}): Promise<BackupResult> {
|
|
125
144
|
const env = opts.env ?? process.env;
|
|
@@ -149,7 +168,91 @@ export async function createDump(opts: {
|
|
|
149
168
|
});
|
|
150
169
|
|
|
151
170
|
const sizeBytes = fs.existsSync(localFile) ? fs.statSync(localFile).size : 0;
|
|
152
|
-
|
|
171
|
+
|
|
172
|
+
const result: BackupResult = { localFile, fileName, sizeBytes };
|
|
173
|
+
|
|
174
|
+
if (opts.includeGlobals !== false) {
|
|
175
|
+
const dumpallBin = resolvePgBinary("pg_dumpall", env);
|
|
176
|
+
if (!dumpallBin) {
|
|
177
|
+
throw new BackupToolError(
|
|
178
|
+
"Could not find the 'pg_dumpall' binary needed to capture cluster roles.",
|
|
179
|
+
"Install the PostgreSQL client tools or set PG_DUMPALL_PATH. " +
|
|
180
|
+
"To take a role-incomplete backup anyway, pass includeGlobals: false."
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const globalsFile = globalsFileForDump(localFile);
|
|
184
|
+
await execa(
|
|
185
|
+
dumpallBin,
|
|
186
|
+
buildPgDumpallGlobalsArgs({ connectionString: opts.connectionString, outFile: globalsFile }),
|
|
187
|
+
{ stdio: opts.inheritStdio ? "inherit" : "pipe", env: { ...(env as Record<string, string>) } }
|
|
188
|
+
);
|
|
189
|
+
result.globalsFile = globalsFile;
|
|
190
|
+
result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Cheap integrity check on a freshly written dump: it must be non-empty and
|
|
198
|
+
* `pg_restore --list` must parse its table of contents without error. Used
|
|
199
|
+
* before pruning older backups so a corrupt-but-exit-0 dump never becomes
|
|
200
|
+
* the reason the last good backup is deleted.
|
|
201
|
+
*/
|
|
202
|
+
export async function validateDump(
|
|
203
|
+
localFile: string,
|
|
204
|
+
env: Record<string, string | undefined> = process.env
|
|
205
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
206
|
+
if (!fs.existsSync(localFile)) {
|
|
207
|
+
return { ok: false, reason: `Dump file does not exist: ${localFile}` };
|
|
208
|
+
}
|
|
209
|
+
if (fs.statSync(localFile).size === 0) {
|
|
210
|
+
return { ok: false, reason: "Dump file is empty (0 bytes)." };
|
|
211
|
+
}
|
|
212
|
+
const bin = resolvePgBinary("pg_restore", env);
|
|
213
|
+
if (!bin) {
|
|
214
|
+
// Can't verify without pg_restore; treat as inconclusive-but-fail so
|
|
215
|
+
// pruning doesn't proceed on an unverified dump.
|
|
216
|
+
return { ok: false, reason: "Could not find 'pg_restore' to verify the dump." };
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await execa(bin, buildPgRestoreListArgs(localFile), {
|
|
220
|
+
stdio: "pipe",
|
|
221
|
+
env: { ...(env as Record<string, string>) }
|
|
222
|
+
});
|
|
223
|
+
return { ok: true };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { ok: false, reason: `pg_restore --list failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Replay a `pg_dumpall --globals-only` script to recreate cluster roles
|
|
231
|
+
* before a restore, so the dump's GRANT/RLS statements (which reference
|
|
232
|
+
* `rebase_user` and any owner roles) actually apply. Runs statement by
|
|
233
|
+
* statement and tolerates per-statement failures — on a same-cluster restore
|
|
234
|
+
* the roles usually already exist (`CREATE ROLE` → "already exists"), and on
|
|
235
|
+
* a managed provider an `ALTER ROLE <superuser>` may be refused; neither
|
|
236
|
+
* should abort role recreation. Returns how many statements applied vs were
|
|
237
|
+
* skipped.
|
|
238
|
+
*/
|
|
239
|
+
export async function applyGlobals(
|
|
240
|
+
connectionString: string,
|
|
241
|
+
globalsSql: string,
|
|
242
|
+
log: (message: string) => void = () => {}
|
|
243
|
+
): Promise<{ applied: number; skipped: number }> {
|
|
244
|
+
const { Client } = await import("pg");
|
|
245
|
+
const client = new Client({ connectionString });
|
|
246
|
+
await client.connect();
|
|
247
|
+
try {
|
|
248
|
+
return await applyGlobalsWith(
|
|
249
|
+
async (sql) => { await client.query(sql); },
|
|
250
|
+
globalsSql,
|
|
251
|
+
log
|
|
252
|
+
);
|
|
253
|
+
} finally {
|
|
254
|
+
await client.end();
|
|
255
|
+
}
|
|
153
256
|
}
|
|
154
257
|
|
|
155
258
|
/**
|
|
@@ -157,12 +260,18 @@ export async function createDump(opts: {
|
|
|
157
260
|
* `connectionString`. Destructive when `clean` is set (drops objects
|
|
158
261
|
* first). Never called automatically — the CLI gates it behind explicit
|
|
159
262
|
* confirmation.
|
|
263
|
+
*
|
|
264
|
+
* Runs with `--exit-on-error` by default: a restore that logs-and-continues
|
|
265
|
+
* past a failed GRANT (because a role was missing) reports success with RLS
|
|
266
|
+
* un-enforced. Callers should recreate roles first (see {@link applyGlobals})
|
|
267
|
+
* and only set `exitOnError: false` deliberately.
|
|
160
268
|
*/
|
|
161
269
|
export async function restoreDump(opts: {
|
|
162
270
|
connectionString: string;
|
|
163
271
|
inputFile: string;
|
|
164
272
|
clean?: boolean;
|
|
165
273
|
noOwner?: boolean;
|
|
274
|
+
exitOnError?: boolean;
|
|
166
275
|
inheritStdio?: boolean;
|
|
167
276
|
env?: Record<string, string | undefined>;
|
|
168
277
|
}): Promise<void> {
|
|
@@ -178,7 +287,8 @@ export async function restoreDump(opts: {
|
|
|
178
287
|
connectionString: opts.connectionString,
|
|
179
288
|
inputFile: opts.inputFile,
|
|
180
289
|
clean: opts.clean,
|
|
181
|
-
noOwner: opts.noOwner
|
|
290
|
+
noOwner: opts.noOwner,
|
|
291
|
+
exitOnError: opts.exitOnError
|
|
182
292
|
});
|
|
183
293
|
await execa(bin, args, {
|
|
184
294
|
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
@@ -288,12 +398,21 @@ export async function pruneBackups(
|
|
|
288
398
|
): Promise<string[]> {
|
|
289
399
|
const backups = await listBackups(dest, storage);
|
|
290
400
|
const toDelete = selectBackupsToPrune(backups, options);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
401
|
+
|
|
402
|
+
// Delete each dump together with its `.globals.sql` sidecar (pruneWith
|
|
403
|
+
// handles the pairing + best-effort sidecar). The deleter rejects on a
|
|
404
|
+
// missing key so pruneWith can swallow an absent sidecar.
|
|
405
|
+
const deleteObject =
|
|
406
|
+
dest.kind === "local"
|
|
407
|
+
? async (key: string) => {
|
|
408
|
+
if (!fs.existsSync(key)) throw new Error(`not found: ${key}`);
|
|
409
|
+
fs.unlinkSync(key);
|
|
410
|
+
}
|
|
411
|
+
: async (key: string) => {
|
|
412
|
+
if (!storage) throw new BackupToolError("Storage backend required to prune object backups.");
|
|
413
|
+
await storage.deleteObject(key, dest.bucket);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
await pruneWith(toDelete, deleteObject);
|
|
298
417
|
return toDelete;
|
|
299
418
|
}
|
package/src/backup/pg-tools.ts
CHANGED
|
@@ -202,7 +202,12 @@ export function buildPgRestoreArgs(opts: {
|
|
|
202
202
|
inputFile: string;
|
|
203
203
|
/** Drop objects before recreating them (destructive but idempotent). */
|
|
204
204
|
clean?: boolean;
|
|
205
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Abort on the first error instead of logging and continuing. Defaults
|
|
207
|
+
* ON: a restore that silently skips failed GRANT/RLS statements (because
|
|
208
|
+
* a role is missing) "succeeds" with RLS un-enforced — a security hole.
|
|
209
|
+
* Fail loudly instead so the operator knows the restore is incomplete.
|
|
210
|
+
*/
|
|
206
211
|
exitOnError?: boolean;
|
|
207
212
|
noOwner?: boolean;
|
|
208
213
|
}): string[] {
|
|
@@ -213,13 +218,76 @@ export function buildPgRestoreArgs(opts: {
|
|
|
213
218
|
if (opts.noOwner) {
|
|
214
219
|
args.push("--no-owner");
|
|
215
220
|
}
|
|
216
|
-
|
|
221
|
+
// Default to --exit-on-error unless explicitly disabled.
|
|
222
|
+
if (opts.exitOnError !== false) {
|
|
217
223
|
args.push("--exit-on-error");
|
|
218
224
|
}
|
|
219
225
|
args.push(opts.inputFile);
|
|
220
226
|
return args;
|
|
221
227
|
}
|
|
222
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Assemble the `pg_restore --list` argument vector. Reading a dump's table
|
|
231
|
+
* of contents parses the whole archive without touching a database, so it is
|
|
232
|
+
* a cheap integrity check that the file isn't truncated or corrupt.
|
|
233
|
+
*/
|
|
234
|
+
export function buildPgRestoreListArgs(inputFile: string): string[] {
|
|
235
|
+
return ["--list", inputFile];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
|
|
240
|
+
* cluster-wide objects) live outside any single database, so a per-database
|
|
241
|
+
* `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
|
|
242
|
+
* statements in the main dump fail on restore and RLS is silently lost — so
|
|
243
|
+
* every backup captures the globals into a sidecar `.globals.sql`.
|
|
244
|
+
*
|
|
245
|
+
* `--no-role-passwords` keeps role secrets out of the artifact (backups may
|
|
246
|
+
* be shipped off-box); roles are recreated password-less and re-secured by
|
|
247
|
+
* the operator.
|
|
248
|
+
*/
|
|
249
|
+
export function buildPgDumpallGlobalsArgs(opts: {
|
|
250
|
+
connectionString: string;
|
|
251
|
+
outFile: string;
|
|
252
|
+
}): string[] {
|
|
253
|
+
return [
|
|
254
|
+
"--globals-only",
|
|
255
|
+
"--no-role-passwords",
|
|
256
|
+
"--no-password",
|
|
257
|
+
`--file=${opts.outFile}`,
|
|
258
|
+
`--dbname=${opts.connectionString}`
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Derive the globals sidecar path/key for a given `.dump` file. Keeps the
|
|
264
|
+
* two artifacts adjacent so listing, uploading and pruning can find one from
|
|
265
|
+
* the other. A name that doesn't end in `.dump` is returned unchanged with a
|
|
266
|
+
* `.globals.sql` suffix appended.
|
|
267
|
+
*/
|
|
268
|
+
export function globalsFileForDump(dumpPath: string): string {
|
|
269
|
+
return dumpPath.endsWith(".dump")
|
|
270
|
+
? dumpPath.slice(0, -".dump".length) + ".globals.sql"
|
|
271
|
+
: dumpPath + ".globals.sql";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Split a `pg_dumpall --globals-only` script into individual statements.
|
|
276
|
+
* Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
|
|
277
|
+
* can run independently and a benign "role already exists" on one doesn't
|
|
278
|
+
* abort the rest. Drops `--` comment lines and blank statements.
|
|
279
|
+
*/
|
|
280
|
+
export function splitGlobalsStatements(sql: string): string[] {
|
|
281
|
+
const withoutComments = sql
|
|
282
|
+
.split("\n")
|
|
283
|
+
.filter((line) => !line.trim().startsWith("--"))
|
|
284
|
+
.join("\n");
|
|
285
|
+
return withoutComments
|
|
286
|
+
.split(";")
|
|
287
|
+
.map((s) => s.trim())
|
|
288
|
+
.filter((s) => s.length > 0);
|
|
289
|
+
}
|
|
290
|
+
|
|
223
291
|
/**
|
|
224
292
|
* Resolve the Postgres connection string the backup commands should use,
|
|
225
293
|
* mirroring the precedence the branch command already relies on.
|
package/src/cli-helpers.ts
CHANGED
|
@@ -143,36 +143,91 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Query the live database for every user table/view outside the system
|
|
148
|
+
* catalogs. Separated from {@link getTableExcludes} so its failure mode can
|
|
149
|
+
* be handled explicitly (fail closed) and so tests can inject a stub.
|
|
150
|
+
*/
|
|
151
|
+
export async function queryExistingTables(databaseUrl: string): Promise<string[]> {
|
|
152
|
+
const { Client } = await import("pg");
|
|
153
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
154
|
+
await client.connect();
|
|
150
155
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
156
|
+
const res = await client.query(`
|
|
157
|
+
SELECT table_schema || '.' || table_name AS full_name
|
|
158
|
+
FROM information_schema.tables
|
|
159
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
160
|
+
AND table_type IN ('BASE TABLE', 'VIEW');
|
|
161
|
+
`);
|
|
162
|
+
return res.rows.map((row: { full_name: string }) => row.full_name);
|
|
163
|
+
} finally {
|
|
164
|
+
await client.end();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Raised when the exclude list could not be built. `db push` MUST abort on
|
|
170
|
+
* this rather than continue: the exclude list is the only thing shielding
|
|
171
|
+
* non-collection (user/system) tables from the auto-approved declarative
|
|
172
|
+
* apply. A partial list — the old fail-open behaviour — meant a transient
|
|
173
|
+
* introspection hiccup dropped every table not present in `schema.sql`.
|
|
174
|
+
*/
|
|
175
|
+
export class ExcludeIntrospectionError extends Error {
|
|
176
|
+
constructor(message: string, readonly cause?: unknown) {
|
|
177
|
+
super(message);
|
|
178
|
+
this.name = "ExcludeIntrospectionError";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build the `--exclude` list that protects tables Rebase doesn't manage from
|
|
184
|
+
* the declarative apply. Anything not backing a collection (or its M2M
|
|
185
|
+
* junctions) is excluded so Atlas never drops it.
|
|
186
|
+
*
|
|
187
|
+
* Fails **closed**: if the database can't be introspected we cannot know
|
|
188
|
+
* which tables to protect, so we throw {@link ExcludeIntrospectionError}
|
|
189
|
+
* instead of returning a near-empty list and letting the caller drop
|
|
190
|
+
* everything.
|
|
191
|
+
*
|
|
192
|
+
* `deps` is injectable for tests; production uses the real pg-backed queries.
|
|
193
|
+
*/
|
|
194
|
+
export async function getTableExcludes(
|
|
195
|
+
databaseUrl: string,
|
|
196
|
+
collectionsPath: string,
|
|
197
|
+
deps: {
|
|
198
|
+
queryExistingTables?: (databaseUrl: string) => Promise<string[]>;
|
|
199
|
+
getIncludes?: (collectionsPath: string) => Promise<string[]>;
|
|
200
|
+
} = {}
|
|
201
|
+
): Promise<string[]> {
|
|
202
|
+
const getIncludes = deps.getIncludes ?? getTableIncludes;
|
|
203
|
+
const queryTables = deps.queryExistingTables ?? queryExistingTables;
|
|
204
|
+
|
|
205
|
+
const includes = await getIncludes(collectionsPath);
|
|
206
|
+
// Framework-owned schemas Rebase manages itself — the declarative apply
|
|
207
|
+
// must never touch them. `auth` holds the helper functions; `rebase` holds
|
|
208
|
+
// the auth tables (users, refresh_tokens, …) and Atlas's own revision
|
|
209
|
+
// table. Excluding both the schema object AND its contents keeps Atlas
|
|
210
|
+
// from planning a `DROP SCHEMA … CASCADE` — which on a live database would
|
|
211
|
+
// take the user/auth tables with it. `atlas_schema_revisions.*` is kept
|
|
212
|
+
// for backwards-compatibility with any external revision schema.
|
|
213
|
+
const excludes: string[] = ["atlas_schema_revisions.*", "auth", "auth.*", "rebase", "rebase.*"];
|
|
214
|
+
|
|
215
|
+
let existingTables: string[];
|
|
216
|
+
try {
|
|
217
|
+
existingTables = await queryTables(databaseUrl);
|
|
172
218
|
} catch (err) {
|
|
173
|
-
|
|
219
|
+
throw new ExcludeIntrospectionError(
|
|
220
|
+
`Failed to introspect the database for unmapped tables: ${err instanceof Error ? err.message : String(err)}`,
|
|
221
|
+
err
|
|
222
|
+
);
|
|
174
223
|
}
|
|
175
|
-
|
|
224
|
+
|
|
225
|
+
for (const table of existingTables) {
|
|
226
|
+
if (!includes.includes(table)) {
|
|
227
|
+
excludes.push(table);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
176
231
|
return excludes;
|
|
177
232
|
}
|
|
178
233
|
|
package/src/cli.ts
CHANGED
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
getTableIncludes,
|
|
11
11
|
getDevDatabaseUrl,
|
|
12
12
|
ensureDevDatabaseExists,
|
|
13
|
-
getTableExcludes
|
|
13
|
+
getTableExcludes,
|
|
14
|
+
ExcludeIntrospectionError
|
|
14
15
|
} from "./cli-helpers";
|
|
15
16
|
import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
|
|
16
17
|
import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
|
|
18
|
+
import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
|
|
19
|
+
import readline from "readline";
|
|
17
20
|
|
|
18
21
|
const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
22
|
|
|
@@ -87,7 +90,10 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
87
90
|
const argsList = arg(
|
|
88
91
|
{
|
|
89
92
|
"--collections": String,
|
|
90
|
-
"-
|
|
93
|
+
"--allow-destructive": Boolean,
|
|
94
|
+
"--yes": Boolean,
|
|
95
|
+
"-c": "--collections",
|
|
96
|
+
"-y": "--yes"
|
|
91
97
|
},
|
|
92
98
|
{
|
|
93
99
|
argv: rawArgs.slice(2),
|
|
@@ -171,10 +177,60 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
171
177
|
if (databaseUrl) {
|
|
172
178
|
await ensureAuthSchemaAndFunctions(databaseUrl);
|
|
173
179
|
}
|
|
180
|
+
|
|
181
|
+
// Preview the plan before touching data. `atlas schema apply` with
|
|
182
|
+
// --auto-approve will silently DROP COLUMN on a field removal and
|
|
183
|
+
// drop+add on a rename, so we first dry-run to obtain the planned
|
|
184
|
+
// SQL and gate anything destructive.
|
|
185
|
+
const plan = await runAtlas(
|
|
186
|
+
"schema",
|
|
187
|
+
["apply", "--to", "file://drizzle/schema.sql", "--dry-run"],
|
|
188
|
+
collectionsPath,
|
|
189
|
+
{ captureStdout: true }
|
|
190
|
+
);
|
|
191
|
+
const destructive = detectDestructiveStatements(plan);
|
|
192
|
+
const allowDestructive = argsList["--allow-destructive"] === true || argsList["--yes"] === true;
|
|
193
|
+
const decision = decidePushSafety({
|
|
194
|
+
destructiveCount: destructive.length,
|
|
195
|
+
allowDestructive,
|
|
196
|
+
interactive: process.stdin.isTTY === true
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (destructive.length > 0) {
|
|
200
|
+
logger.warn(chalk.yellow(` ⚠️ This push includes ${destructive.length} destructive change(s) that will DESTROY data:`));
|
|
201
|
+
logger.warn("");
|
|
202
|
+
for (const d of destructive) {
|
|
203
|
+
logger.warn(chalk.red(` ${d.kind}: `) + chalk.gray(d.statement.replace(/\s+/g, " ")));
|
|
204
|
+
}
|
|
205
|
+
logger.warn("");
|
|
206
|
+
logger.warn(chalk.yellow(" Full planned changes:"));
|
|
207
|
+
logger.warn(chalk.gray(plan.trim().split("\n").map((l) => ` ${l}`).join("\n")));
|
|
208
|
+
logger.warn("");
|
|
209
|
+
|
|
210
|
+
if (decision === "refuse") {
|
|
211
|
+
logger.error(chalk.red(" ✗ Aborting: destructive changes require confirmation."));
|
|
212
|
+
logger.error(chalk.gray(" Re-run interactively, or pass --allow-destructive to proceed. Back up first: rebase db backup"));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
if (decision === "confirm") {
|
|
216
|
+
const confirmed = await promptConfirm(
|
|
217
|
+
chalk.yellow(" Type 'yes' to apply these destructive changes (this cannot be undone): ")
|
|
218
|
+
);
|
|
219
|
+
if (!confirmed) {
|
|
220
|
+
logger.info(chalk.gray(" Aborted. No changes were made."));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
// decision === "apply" via --allow-destructive
|
|
225
|
+
logger.warn(chalk.yellow(" Proceeding because --allow-destructive was passed."));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
174
229
|
await runAtlas("schema", ["apply", "--to", "file://drizzle/schema.sql", "--auto-approve"], collectionsPath);
|
|
175
230
|
logger.info("");
|
|
176
231
|
|
|
177
232
|
if (databaseUrl) {
|
|
233
|
+
await ensureAuthTables(databaseUrl, collectionsPath);
|
|
178
234
|
await applyPolicies(databaseUrl);
|
|
179
235
|
await reconcilePolicies(databaseUrl, collectionsPath);
|
|
180
236
|
await ensureRlsUserRole(databaseUrl);
|
|
@@ -218,6 +274,43 @@ async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void>
|
|
|
218
274
|
}
|
|
219
275
|
}
|
|
220
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Create the framework auth tables (e.g. `rebase.users`) that the generated RLS
|
|
279
|
+
* policies reference, before `applyPolicies` runs. Mirrors what the server does
|
|
280
|
+
* at boot (`PostgresBootstrapper.initializeAuth` → `ensureAuthTablesExist`).
|
|
281
|
+
*
|
|
282
|
+
* Without this, `rebase db push` against a database that has never booted the
|
|
283
|
+
* server fails while applying policies with `relation "rebase.users" does not
|
|
284
|
+
* exist` — the documented first-run does `db push` *before* the first `dev`.
|
|
285
|
+
* `ensureAuthTablesExist` is idempotent (CREATE TABLE IF NOT EXISTS), so it is
|
|
286
|
+
* safe to run on every push and harmless once the server has also created them.
|
|
287
|
+
*/
|
|
288
|
+
async function ensureAuthTables(databaseUrl: string, collectionsPath: string): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
291
|
+
const { ensureAuthTablesExist } = await import("./auth/ensure-tables");
|
|
292
|
+
const { loadCollections } = await import("./schema/doctor");
|
|
293
|
+
const { Client } = await import("pg");
|
|
294
|
+
|
|
295
|
+
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
296
|
+
// The auth collection is flagged with `auth: true` or `auth: { enabled: true }`.
|
|
297
|
+
const authCollection = collections.find((c) => {
|
|
298
|
+
const a = c.auth;
|
|
299
|
+
return a === true || (typeof a === "object" && a !== null && a.enabled === true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
303
|
+
await client.connect();
|
|
304
|
+
try {
|
|
305
|
+
await ensureAuthTablesExist(drizzle(client), authCollection);
|
|
306
|
+
} finally {
|
|
307
|
+
await client.end();
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
logger.warn(chalk.yellow(` ⚠️ Failed to ensure framework auth tables: ${err instanceof Error ? err.message : String(err)}`));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
221
314
|
/**
|
|
222
315
|
* Provision the restricted `rebase_user` role right after schema changes land,
|
|
223
316
|
* so grants cover freshly created tables (default privileges cover future
|
|
@@ -517,7 +610,29 @@ function timeAgo(date: Date): string {
|
|
|
517
610
|
|
|
518
611
|
|
|
519
612
|
|
|
520
|
-
|
|
613
|
+
/**
|
|
614
|
+
* Ask a yes/no question on an interactive terminal. Non-interactive shells
|
|
615
|
+
* (CI, pipes, agents) can't answer, so this must only be reached after
|
|
616
|
+
* {@link decidePushSafety} has already ruled that interactive confirmation is
|
|
617
|
+
* possible.
|
|
618
|
+
*/
|
|
619
|
+
async function promptConfirm(question: string): Promise<boolean> {
|
|
620
|
+
if (!process.stdin.isTTY) return false;
|
|
621
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
622
|
+
try {
|
|
623
|
+
const answer: string = await new Promise((resolve) => rl.question(question, resolve));
|
|
624
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
625
|
+
} finally {
|
|
626
|
+
rl.close();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function runAtlas(
|
|
631
|
+
domain: "schema" | "migrate",
|
|
632
|
+
args: string[],
|
|
633
|
+
collectionsPath?: string,
|
|
634
|
+
opts: { captureStdout?: boolean } = {}
|
|
635
|
+
): Promise<string> {
|
|
521
636
|
const atlasBin = resolveLocalBin("atlas");
|
|
522
637
|
if (!atlasBin) {
|
|
523
638
|
logger.error(chalk.red("✗ Could not find atlas binary."));
|
|
@@ -587,7 +702,24 @@ async function runAtlas(domain: "schema" | "migrate", args: string[], collection
|
|
|
587
702
|
}
|
|
588
703
|
|
|
589
704
|
if (domain === "schema" && args.includes("apply") && collectionsPath) {
|
|
590
|
-
|
|
705
|
+
// Fail CLOSED: the exclude list is the only thing shielding
|
|
706
|
+
// non-collection tables from the auto-approved apply. If we can't
|
|
707
|
+
// introspect the database to build it, abort rather than proceed with
|
|
708
|
+
// a partial list that would let Atlas drop unmanaged tables.
|
|
709
|
+
let excludes: string[];
|
|
710
|
+
try {
|
|
711
|
+
excludes = await getTableExcludes(databaseUrl, collectionsPath);
|
|
712
|
+
} catch (err) {
|
|
713
|
+
if (err instanceof ExcludeIntrospectionError) {
|
|
714
|
+
logger.error(chalk.red("\n✗ Aborting push: could not determine which tables to protect."));
|
|
715
|
+
logger.error(chalk.gray(` ${err.message}`));
|
|
716
|
+
logger.error(chalk.gray(" Refusing to apply — a partial exclude list could drop tables Rebase does not manage."));
|
|
717
|
+
const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
|
|
718
|
+
if (hint) logger.error(hint);
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
591
723
|
for (const exc of excludes) {
|
|
592
724
|
atlasArgs.push("--exclude", exc);
|
|
593
725
|
}
|
|
@@ -595,13 +727,20 @@ async function runAtlas(domain: "schema" | "migrate", args: string[], collection
|
|
|
595
727
|
|
|
596
728
|
// Stream stdout live but tee stderr so we can inspect Atlas's error text
|
|
597
729
|
// for known, actionable failure modes (e.g. a dependency-drop that leaves
|
|
598
|
-
// the schema half-applied) after the process exits.
|
|
730
|
+
// the schema half-applied) after the process exits. When capturing (used
|
|
731
|
+
// for the destructive-change dry-run), pipe stdout and collect it instead.
|
|
599
732
|
const subprocess = execa(atlasBin, atlasArgs, {
|
|
600
733
|
cwd: process.cwd(),
|
|
601
|
-
stdout: "inherit",
|
|
734
|
+
stdout: opts.captureStdout ? "pipe" : "inherit",
|
|
602
735
|
stderr: "pipe",
|
|
603
736
|
env
|
|
604
737
|
});
|
|
738
|
+
let stdoutText = "";
|
|
739
|
+
if (opts.captureStdout) {
|
|
740
|
+
subprocess.stdout?.on("data", (chunk: Buffer) => {
|
|
741
|
+
stdoutText += chunk.toString();
|
|
742
|
+
});
|
|
743
|
+
}
|
|
605
744
|
let stderrText = "";
|
|
606
745
|
subprocess.stderr?.on("data", (chunk: Buffer) => {
|
|
607
746
|
const text = chunk.toString();
|
|
@@ -619,6 +758,13 @@ async function runAtlas(domain: "schema" | "migrate", args: string[], collection
|
|
|
619
758
|
}
|
|
620
759
|
process.exit(1);
|
|
621
760
|
}
|
|
761
|
+
// Atlas prints the plan to stdout, but fall back to stderr so the
|
|
762
|
+
// destructive-change detector never misses a plan on a version/build that
|
|
763
|
+
// routes it differently.
|
|
764
|
+
if (opts.captureStdout) {
|
|
765
|
+
return stdoutText.trim().length > 0 ? stdoutText : stderrText;
|
|
766
|
+
}
|
|
767
|
+
return "";
|
|
622
768
|
}
|
|
623
769
|
|
|
624
770
|
async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,10 @@ export * from "./schema/generate-drizzle-schema-logic";
|
|
|
7
7
|
export * from "./schema/generate-drizzle-schema";
|
|
8
8
|
export * from "./utils/drizzle-conditions";
|
|
9
9
|
export * from "./services/realtimeService";
|
|
10
|
+
// The channel bus is a public extension point: a transport published as its own
|
|
11
|
+
// package implements `ChannelBus` (declared in @rebasepro/types and re-exported
|
|
12
|
+
// here) and is passed to `realtime.bus`. Without this export it would be one.
|
|
13
|
+
export * from "./services/channel-bus";
|
|
10
14
|
export * from "./websocket";
|
|
11
15
|
export * from "./collections/PostgresCollectionRegistry";
|
|
12
16
|
export * from "./services/BranchService";
|