@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7
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/PostgresBackendDriver.d.ts +49 -2
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
- package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-Bww-Lg0s.js +8999 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -0
- package/dist/cli-helpers.d.ts +40 -1
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
- package/dist/index.es.js +1701 -10167
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CPkCqVTz.js +105 -0
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +45 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +14 -8
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/services/dataService.d.ts +5 -1
- package/dist/services/realtimeService.d.ts +25 -21
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-D0TBU3ia.js +530 -0
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +169 -3
- package/src/PostgresBootstrapper.ts +233 -35
- package/src/auth/ensure-tables.ts +335 -81
- package/src/auth/services.ts +24 -2
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +92 -9
- package/src/cli.ts +219 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +22 -6
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +301 -44
- package/src/schema/generate-drizzle-schema-logic.ts +49 -7
- package/src/schema/generate-postgres-ddl-logic.ts +474 -27
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +385 -71
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +11 -3
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +100 -107
- package/src/services/PersistService.ts +34 -7
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +5 -1
- package/src/services/realtimeService.ts +38 -35
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/cli.ts
CHANGED
|
@@ -5,18 +5,21 @@ import path from "path";
|
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { logger } from "@rebasepro/server";
|
|
8
|
+
import { formatRelativeTime } from "@rebasepro/utils";
|
|
8
9
|
import {
|
|
10
|
+
diagnoseMissingBin,
|
|
9
11
|
resolveLocalBin,
|
|
10
12
|
getTableIncludes,
|
|
11
13
|
getDevDatabaseUrl,
|
|
12
14
|
ensureDevDatabaseExists,
|
|
13
15
|
getTableExcludes,
|
|
14
|
-
ExcludeIntrospectionError
|
|
16
|
+
ExcludeIntrospectionError,
|
|
17
|
+
promptConfirm
|
|
15
18
|
} from "./cli-helpers";
|
|
16
19
|
import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
|
|
17
|
-
import {
|
|
20
|
+
import { forLibpq } from "./utils/connection-string";
|
|
21
|
+
import { dropLegacyAuthSchema, RLS_BOOTSTRAP_SQL } from "./schema/rls-bootstrap-sql";
|
|
18
22
|
import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
|
|
19
|
-
import readline from "readline";
|
|
20
23
|
|
|
21
24
|
const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
25
|
|
|
@@ -136,14 +139,14 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
136
139
|
);
|
|
137
140
|
fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
|
|
138
141
|
|
|
139
|
-
// Append RLS policies, preceded by the
|
|
142
|
+
// Append RLS policies, preceded by the RLS bootstrap so the
|
|
140
143
|
// migration is self-contained: Atlas replays migrations
|
|
141
|
-
// against a clean dev database where `
|
|
142
|
-
// otherwise exist.
|
|
144
|
+
// against a clean dev database where `rebase.uid()` would
|
|
145
|
+
// not otherwise exist.
|
|
143
146
|
const policiesFile = path.resolve(process.cwd(), "drizzle", "policies.sql");
|
|
144
147
|
if (fs.existsSync(policiesFile)) {
|
|
145
148
|
const policiesContent = fs.readFileSync(policiesFile, "utf-8");
|
|
146
|
-
fs.appendFileSync(newestMigrationFile, "\n\n" +
|
|
149
|
+
fs.appendFileSync(newestMigrationFile, "\n\n" + RLS_BOOTSTRAP_SQL + "\n" + policiesContent);
|
|
147
150
|
logger.info(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
|
|
148
151
|
|
|
149
152
|
// Re-hash the migration directory
|
|
@@ -234,6 +237,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
234
237
|
await applyPolicies(databaseUrl);
|
|
235
238
|
await reconcilePolicies(databaseUrl, collectionsPath);
|
|
236
239
|
await ensureRlsUserRole(databaseUrl);
|
|
240
|
+
await retireLegacyAuthSchema(databaseUrl);
|
|
237
241
|
} else {
|
|
238
242
|
logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
|
|
239
243
|
}
|
|
@@ -246,6 +250,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
246
250
|
await runAtlas("migrate", ["apply", "--dir", "file://drizzle/migrations", ...extraArgs], collectionsPath);
|
|
247
251
|
if (databaseUrl) {
|
|
248
252
|
await ensureRlsUserRole(databaseUrl);
|
|
253
|
+
await retireLegacyAuthSchema(databaseUrl);
|
|
249
254
|
}
|
|
250
255
|
}
|
|
251
256
|
|
|
@@ -261,16 +266,20 @@ async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void>
|
|
|
261
266
|
const client = new Client({ connectionString: databaseUrl });
|
|
262
267
|
await client.connect();
|
|
263
268
|
try {
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
+
// Creates the `rebase` schema as its first statement, which also
|
|
270
|
+
// covers the schema Atlas puts its revision table in
|
|
271
|
+
// (`--revisions-schema rebase`). That used to need a separate,
|
|
272
|
+
// deliberately migration-stream-excluded statement here, because the
|
|
273
|
+
// helper functions lived in `auth` and creating `rebase` from the
|
|
274
|
+
// preamble would have made Atlas plan a DROP for it. The generator
|
|
275
|
+
// now always declares `rebase` in the desired schema, so there is
|
|
276
|
+
// nothing to keep out.
|
|
277
|
+
await client.query(RLS_BOOTSTRAP_SQL);
|
|
269
278
|
} finally {
|
|
270
279
|
await client.end();
|
|
271
280
|
}
|
|
272
281
|
} catch (err) {
|
|
273
|
-
logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap
|
|
282
|
+
logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap the RLS helper functions: ${err instanceof Error ? err.message : String(err)}`));
|
|
274
283
|
}
|
|
275
284
|
}
|
|
276
285
|
|
|
@@ -318,19 +327,82 @@ async function ensureAuthTables(databaseUrl: string, collectionsPath: string): P
|
|
|
318
327
|
* connection would bypass RLS (superuser / BYPASSRLS / table owner).
|
|
319
328
|
*/
|
|
320
329
|
async function ensureRlsUserRole(databaseUrl: string): Promise<void> {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
330
|
+
// Every failure in here used to escape uncaught, and `db migrate`'s caller
|
|
331
|
+
// turns that into a bare `process.exit(1)` — so the migration would apply,
|
|
332
|
+
// print `-- ok`, and then the command would die with NO output whatsoever
|
|
333
|
+
// and a non-zero status. Seen with a module-resolution error inside the
|
|
334
|
+
// dynamic import, where the entire diagnosis was one silent exit code.
|
|
335
|
+
//
|
|
336
|
+
// Reported rather than rethrown, and deliberately not fatal: the schema
|
|
337
|
+
// change has already landed at this point, so failing the command implies a
|
|
338
|
+
// rollback that did not happen. What is actually lost is the role
|
|
339
|
+
// provisioning, and the message says so and how to finish it by hand.
|
|
340
|
+
// No `auth`: the RLS helpers live in `rebase` now, and a schema the
|
|
341
|
+
// framework no longer creates must not be granted on — on a Supabase
|
|
342
|
+
// database that would hand the end-user role USAGE on their auth schema.
|
|
343
|
+
const schemas = ["public", "rebase"];
|
|
344
|
+
let rls: typeof import("./security/rls-enforcement");
|
|
325
345
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
346
|
+
rls = await import("./security/rls-enforcement");
|
|
347
|
+
} catch (err) {
|
|
348
|
+
// A module-resolution failure here is a build problem, not a database
|
|
349
|
+
// one, and it is exactly the case that used to produce the silent exit.
|
|
350
|
+
logger.error(chalk.red(
|
|
351
|
+
`\n ✗ Could not load the RLS provisioning module: ${err instanceof Error ? err.message : String(err)}`
|
|
352
|
+
));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const { Client } = await import("pg");
|
|
358
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
359
|
+
await client.connect();
|
|
360
|
+
try {
|
|
361
|
+
const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
|
|
362
|
+
const posture = await rls.detectConnectionPosture(runSql);
|
|
363
|
+
if (posture.privileged) {
|
|
364
|
+
await rls.ensureAppRole(runSql, schemas);
|
|
365
|
+
logger.info(chalk.gray(` ✓ RLS role "${rls.REBASE_USER_ROLE}" provisioned/refreshed.`));
|
|
366
|
+
}
|
|
367
|
+
} finally {
|
|
368
|
+
await client.end();
|
|
331
369
|
}
|
|
332
|
-
}
|
|
333
|
-
|
|
370
|
+
} catch (err) {
|
|
371
|
+
logger.error(chalk.red(
|
|
372
|
+
`\n ✗ The schema change was applied, but the "${rls.REBASE_USER_ROLE}" role could not be ` +
|
|
373
|
+
`provisioned: ${err instanceof Error ? err.message : String(err)}`
|
|
374
|
+
));
|
|
375
|
+
logger.error(chalk.gray(rls.appRoleSetupInstructions("your database user", schemas)));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Retire the pre-1.0 `auth` schema, once nothing depends on it any more.
|
|
381
|
+
*
|
|
382
|
+
* Runs last on purpose. Postgres refuses to drop a function an RLS policy still
|
|
383
|
+
* calls, so this only succeeds after the policies above have been rewritten to
|
|
384
|
+
* `rebase.uid()`. See `dropLegacyAuthSchema` for what it reports when something
|
|
385
|
+
* hand-written is still holding the schema open, and DROP_LEGACY_AUTH_SCHEMA_SQL
|
|
386
|
+
* for the guards that keep it off a Supabase `auth` schema.
|
|
387
|
+
*/
|
|
388
|
+
async function retireLegacyAuthSchema(databaseUrl: string): Promise<void> {
|
|
389
|
+
try {
|
|
390
|
+
const { Client } = await import("pg");
|
|
391
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
392
|
+
await client.connect();
|
|
393
|
+
try {
|
|
394
|
+
await dropLegacyAuthSchema(
|
|
395
|
+
async (text) => (await client.query(text)).rows as Record<string, unknown>[],
|
|
396
|
+
{
|
|
397
|
+
info: (m) => logger.info(chalk.gray(` ${m}`)),
|
|
398
|
+
warn: (m) => logger.warn(chalk.yellow(` ⚠️ ${m}`))
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
} finally {
|
|
402
|
+
await client.end();
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
// Connection-level failure only; the schema is inert either way.
|
|
334
406
|
}
|
|
335
407
|
}
|
|
336
408
|
|
|
@@ -597,36 +669,15 @@ function formatBytes(bytes: number): string {
|
|
|
597
669
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
598
670
|
}
|
|
599
671
|
|
|
672
|
+
/** A branch's age, for "…created 6d ago". */
|
|
600
673
|
function timeAgo(date: Date): string {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
605
|
-
const hours = Math.floor(minutes / 60);
|
|
606
|
-
if (hours < 24) return `${hours}h ago`;
|
|
607
|
-
const days = Math.floor(hours / 24);
|
|
608
|
-
return `${days}d ago`;
|
|
674
|
+
// No horizon: a branch created a year ago should still report an age here,
|
|
675
|
+
// rather than fall back to a bare date in a one-line list entry.
|
|
676
|
+
return formatRelativeTime(date, { maxMs: Number.POSITIVE_INFINITY }) ?? "unknown";
|
|
609
677
|
}
|
|
610
678
|
|
|
611
679
|
|
|
612
680
|
|
|
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
681
|
async function runAtlas(
|
|
631
682
|
domain: "schema" | "migrate",
|
|
632
683
|
args: string[],
|
|
@@ -635,9 +686,36 @@ async function runAtlas(
|
|
|
635
686
|
): Promise<string> {
|
|
636
687
|
const atlasBin = resolveLocalBin("atlas");
|
|
637
688
|
if (!atlasBin) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
689
|
+
// Two very different causes, and the advice for one is a loop for the
|
|
690
|
+
// other — see `diagnoseMissingBin`. This used to say "Install it with:
|
|
691
|
+
// pnpm add -D @ariga/atlas" unconditionally, which is the exact command
|
|
692
|
+
// that produces the far more common of the two states.
|
|
693
|
+
logger.error(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
|
|
694
|
+
|
|
695
|
+
if (diagnoseMissingBin("@ariga/atlas") === "build-script-blocked") {
|
|
696
|
+
logger.error(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
|
|
697
|
+
logger.error(chalk.gray(
|
|
698
|
+
" It downloads that binary in a `preinstall` script, and pnpm 10+ does not\n" +
|
|
699
|
+
" run a dependency's scripts unless you allow it. The install still exits 0,\n" +
|
|
700
|
+
" so the only sign is `Ignored build scripts: @ariga/atlas` in its output.\n"
|
|
701
|
+
));
|
|
702
|
+
logger.error(" Fix it with either:\n");
|
|
703
|
+
logger.error(chalk.bold(" pnpm approve-builds\n"));
|
|
704
|
+
logger.error(" or, to record it in the project (what `rebase init` scaffolds):\n");
|
|
705
|
+
logger.error(chalk.bold(
|
|
706
|
+
" // package.json\n" +
|
|
707
|
+
" \"pnpm\": { \"onlyBuiltDependencies\": [\"@ariga/atlas\"] }\n"
|
|
708
|
+
));
|
|
709
|
+
logger.error(chalk.gray(" Then re-run `pnpm install`.\n"));
|
|
710
|
+
} else {
|
|
711
|
+
logger.error(chalk.gray(" It is not installed in this project.\n"));
|
|
712
|
+
logger.error(" Install it with:\n");
|
|
713
|
+
logger.error(chalk.bold(" pnpm add -D @ariga/atlas\n"));
|
|
714
|
+
logger.error(chalk.gray(
|
|
715
|
+
" If pnpm then reports `Ignored build scripts`, also run `pnpm approve-builds` —\n" +
|
|
716
|
+
" the package carries a `preinstall` script that fetches the binary.\n"
|
|
717
|
+
));
|
|
718
|
+
}
|
|
641
719
|
process.exit(1);
|
|
642
720
|
}
|
|
643
721
|
|
|
@@ -682,19 +760,25 @@ async function runAtlas(
|
|
|
682
760
|
const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
|
|
683
761
|
await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
|
|
684
762
|
|
|
763
|
+
// Atlas speaks libpq, which rejects the `sslmode=no-verify` that
|
|
764
|
+
// node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
|
|
765
|
+
// everything above still connects with the URL as configured.
|
|
766
|
+
const atlasUrl = forLibpq(databaseUrl);
|
|
767
|
+
const atlasDevUrl = forLibpq(devDatabaseUrl);
|
|
768
|
+
|
|
685
769
|
const atlasArgs = [domain, ...args];
|
|
686
770
|
|
|
687
771
|
if (domain === "schema") {
|
|
688
772
|
if (args.includes("apply")) {
|
|
689
|
-
atlasArgs.push("--url",
|
|
773
|
+
atlasArgs.push("--url", atlasUrl, "--dev-url", atlasDevUrl);
|
|
690
774
|
} else if (args.includes("clean") || args.includes("inspect")) {
|
|
691
|
-
atlasArgs.push("--url",
|
|
775
|
+
atlasArgs.push("--url", atlasUrl);
|
|
692
776
|
}
|
|
693
777
|
} else if (domain === "migrate") {
|
|
694
778
|
if (args.includes("diff")) {
|
|
695
|
-
atlasArgs.push("--dev-url",
|
|
779
|
+
atlasArgs.push("--dev-url", atlasDevUrl);
|
|
696
780
|
} else if (args.includes("apply") || args.includes("status")) {
|
|
697
|
-
atlasArgs.push("--url",
|
|
781
|
+
atlasArgs.push("--url", atlasUrl, "--revisions-schema", "rebase");
|
|
698
782
|
if (args.includes("apply")) {
|
|
699
783
|
atlasArgs.push("--allow-dirty");
|
|
700
784
|
}
|
|
@@ -810,7 +894,86 @@ async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
|
|
|
810
894
|
}
|
|
811
895
|
}
|
|
812
896
|
|
|
897
|
+
/**
|
|
898
|
+
* `schema stale [--fix]` — is the generated Drizzle schema older than the rule
|
|
899
|
+
* that derives its foreign-key names?
|
|
900
|
+
*
|
|
901
|
+
* This exists for one upgrade path and is worth the command. 0.13 derives
|
|
902
|
+
* `category_id` where 0.12 derived `categorie_id`; boot-ensure renames the
|
|
903
|
+
* database column to match, and the project's checked-in
|
|
904
|
+
* `backend/src/schema.generated.ts` is then wrong in a way nothing the developer
|
|
905
|
+
* did would explain. Relation validation refuses to boot on it, permanently,
|
|
906
|
+
* because the rename has already been applied and will not run again.
|
|
907
|
+
*
|
|
908
|
+
* `rebase dev` calls this with `--fix` before starting the backend, so the
|
|
909
|
+
* upgrade behaves the way the release note says it does: the column moves and the
|
|
910
|
+
* project keeps working. Without `--fix` it reports and exits non-zero, which is
|
|
911
|
+
* what a build or a CI step wants.
|
|
912
|
+
*/
|
|
913
|
+
async function schemaStaleCommand(rawArgs: string[]): Promise<void> {
|
|
914
|
+
const argsList = arg(
|
|
915
|
+
{
|
|
916
|
+
"--collections": String,
|
|
917
|
+
"--output": String,
|
|
918
|
+
"--fix": Boolean,
|
|
919
|
+
"-c": "--collections",
|
|
920
|
+
"-o": "--output"
|
|
921
|
+
},
|
|
922
|
+
{ argv: rawArgs.slice(2), permissive: true }
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
926
|
+
const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
|
|
927
|
+
const schemaFile = path.resolve(process.cwd(), outputPath);
|
|
928
|
+
|
|
929
|
+
// No generated schema yet is not staleness — a fresh project has not run the
|
|
930
|
+
// generator, and saying "stale" about a file that does not exist would send
|
|
931
|
+
// the reader looking for something to fix.
|
|
932
|
+
if (!fs.existsSync(schemaFile)) return;
|
|
933
|
+
|
|
934
|
+
const { loadCollections } = await import("./schema/doctor");
|
|
935
|
+
const { findLegacyForeignKeyNames, describeLegacyForeignKeyNames } =
|
|
936
|
+
await import("./schema/generated-schema-staleness");
|
|
937
|
+
|
|
938
|
+
let stale;
|
|
939
|
+
try {
|
|
940
|
+
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
941
|
+
stale = findLegacyForeignKeyNames(fs.readFileSync(schemaFile, "utf8"), collections);
|
|
942
|
+
} catch (err) {
|
|
943
|
+
// Best-effort by design: a collections directory that will not load is a
|
|
944
|
+
// real error, but it is one the boot reports far better than this does.
|
|
945
|
+
logger.debug(`schema stale: skipped (${err instanceof Error ? err.message : String(err)})`);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (stale.length === 0) return;
|
|
950
|
+
|
|
951
|
+
logger.info("");
|
|
952
|
+
logger.warn(chalk.yellow(
|
|
953
|
+
` ⚠️ ${outputPath} names ${stale.length} foreign key(s) the way an earlier release did:`
|
|
954
|
+
));
|
|
955
|
+
logger.info(chalk.gray(describeLegacyForeignKeyNames(stale)));
|
|
956
|
+
logger.info("");
|
|
957
|
+
|
|
958
|
+
if (!argsList["--fix"]) {
|
|
959
|
+
logger.error(chalk.red(
|
|
960
|
+
" The database column has already been renamed at boot, so the generated schema no " +
|
|
961
|
+
"longer matches it and the server will refuse to start."
|
|
962
|
+
));
|
|
963
|
+
logger.error(chalk.red(" Run `rebase schema generate` to regenerate it."));
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
logger.info(chalk.gray(" Regenerating the Drizzle schema so it matches..."));
|
|
968
|
+
await schemaCommand("generate", ["schema", "generate", `--collections=${collectionsPath}`, `--output=${outputPath}`]);
|
|
969
|
+
}
|
|
970
|
+
|
|
813
971
|
async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
972
|
+
if (subcommand === "stale") {
|
|
973
|
+
await schemaStaleCommand(rawArgs);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
814
977
|
if (subcommand === "generate") {
|
|
815
978
|
const argsList = arg(
|
|
816
979
|
{
|
|
@@ -2,6 +2,7 @@ import { getTableColumns } from "drizzle-orm";
|
|
|
2
2
|
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
4
4
|
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
5
6
|
|
|
6
7
|
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
7
8
|
|
|
@@ -58,6 +59,68 @@ const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join
|
|
|
58
59
|
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
59
60
|
const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Distinguish "this column name is wrong" from "the generated schema is old".
|
|
64
|
+
*
|
|
65
|
+
* They present identically here — a relation asks for a column the registered
|
|
66
|
+
* table does not have — but they are opposite problems with opposite fixes, and
|
|
67
|
+
* getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
|
|
68
|
+
* projects.
|
|
69
|
+
*
|
|
70
|
+
* The registered table is not the database. It comes from the project's
|
|
71
|
+
* checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
|
|
72
|
+
* derives foreign-key names: `categories` yields `category_id` where it used to
|
|
73
|
+
* yield `categorie_id`. Boot-ensure renames the database column to match, so by
|
|
74
|
+
* the time this runs the *database* is correct and the *generated module* is the
|
|
75
|
+
* stale one. Reporting "not a column" then points at the wrong artifact, and the
|
|
76
|
+
* generic fix — "set `through.targetColumn` to one of: …", listing the legacy
|
|
77
|
+
* name because that is what the stale module still has — talks the reader into
|
|
78
|
+
* pinning a column that no longer exists.
|
|
79
|
+
*
|
|
80
|
+
* So when the wanted name is what the current rule derives, and the table
|
|
81
|
+
* carries what the *previous* rule would have derived from the same source, say
|
|
82
|
+
* that instead.
|
|
83
|
+
*
|
|
84
|
+
* @param wanted the column the relation asks for
|
|
85
|
+
* @param available every column the registered table has
|
|
86
|
+
* @param sources names the default could have been derived from (a slug, a
|
|
87
|
+
* relation name) — checking against these rather than guessing
|
|
88
|
+
* backwards from `wanted` keeps the match exact
|
|
89
|
+
*/
|
|
90
|
+
function staleCodegenRename(
|
|
91
|
+
wanted: string,
|
|
92
|
+
available: Set<string>,
|
|
93
|
+
sources: string[]
|
|
94
|
+
): { legacy: string; current: string } | null {
|
|
95
|
+
for (const source of sources) {
|
|
96
|
+
if (!source) continue;
|
|
97
|
+
const current = generateForeignKeyName(source);
|
|
98
|
+
const legacy = legacyForeignKeyName(source);
|
|
99
|
+
// Only a name that actually moved, and only when the table still has the
|
|
100
|
+
// old spelling and not the new one.
|
|
101
|
+
if (current !== wanted || legacy === current) continue;
|
|
102
|
+
if (available.has(legacy) && !available.has(current)) return { legacy, current };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The shared explanation, so every relation kind reports it identically. */
|
|
108
|
+
function staleCodegenDefect(
|
|
109
|
+
table: string,
|
|
110
|
+
{ legacy, current }: { legacy: string; current: string }
|
|
111
|
+
): Pick<RelationDefect, "problem" | "fix"> {
|
|
112
|
+
return {
|
|
113
|
+
problem:
|
|
114
|
+
`the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this ` +
|
|
115
|
+
`release derives \`${current}\` — the generated schema predates the foreign-key ` +
|
|
116
|
+
"naming fix and no longer describes the database",
|
|
117
|
+
fix:
|
|
118
|
+
"regenerate it with `rebase schema generate` (or `pnpm run schema:generate`). The " +
|
|
119
|
+
"database column has already been renamed for you at boot, so nothing else is needed. " +
|
|
120
|
+
`To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
61
124
|
/**
|
|
62
125
|
* Relations whose names do not resolve against the registered schema.
|
|
63
126
|
*
|
|
@@ -117,11 +180,20 @@ kind: relation.kind };
|
|
|
117
180
|
switch (relation.kind) {
|
|
118
181
|
case "belongsTo": {
|
|
119
182
|
if (!sourceColumns.has(relation.localKey)) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
183
|
+
// `localKey` defaults to the relation name run through
|
|
184
|
+
// the foreign-key rule, so it moves with that rule.
|
|
185
|
+
const stale = staleCodegenRename(
|
|
186
|
+
relation.localKey,
|
|
187
|
+
sourceColumns,
|
|
188
|
+
[relation.relationName, targetCollection.slug]
|
|
189
|
+
);
|
|
190
|
+
defects.push(stale
|
|
191
|
+
? { ...at, ...staleCodegenDefect(sourceTableName, stale) }
|
|
192
|
+
: {
|
|
193
|
+
...at,
|
|
194
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
195
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
196
|
+
});
|
|
125
197
|
}
|
|
126
198
|
break;
|
|
127
199
|
}
|
|
@@ -129,10 +201,34 @@ kind: relation.kind };
|
|
|
129
201
|
case "hasOne":
|
|
130
202
|
case "hasMany": {
|
|
131
203
|
if (!targetColumns.has(relation.foreignKeyOnTarget)) {
|
|
204
|
+
// The default is derived from *this* collection's slug —
|
|
205
|
+
// the column on the target that points back here.
|
|
206
|
+
const stale = staleCodegenRename(
|
|
207
|
+
relation.foreignKeyOnTarget,
|
|
208
|
+
targetColumns,
|
|
209
|
+
[collection.slug]
|
|
210
|
+
);
|
|
211
|
+
defects.push(stale
|
|
212
|
+
? { ...at, ...staleCodegenDefect(targetTableName, stale) }
|
|
213
|
+
: {
|
|
214
|
+
...at,
|
|
215
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
216
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// `sourceKey` is the easiest of the two to put on the wrong
|
|
220
|
+
// side — it is the only column in a `hasMany` that lives
|
|
221
|
+
// here rather than on the target, and naming a target column
|
|
222
|
+
// reads perfectly well right next to `foreignKeyOnTarget`.
|
|
223
|
+
if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) {
|
|
132
224
|
defects.push({
|
|
133
225
|
...at,
|
|
134
|
-
problem: `\`
|
|
135
|
-
fix:
|
|
226
|
+
problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
|
|
227
|
+
fix: targetColumns.has(relation.sourceKey)
|
|
228
|
+
? `it is a column on the *target* table \`${targetTableName}\` — \`sourceKey\` names ` +
|
|
229
|
+
"the column on this collection that the target's foreign key points at, so it " +
|
|
230
|
+
`must be one of: ${quote(sourceColumns)}`
|
|
231
|
+
: `add the column, or set \`sourceKey\` to one of: ${quote(sourceColumns)}`
|
|
136
232
|
});
|
|
137
233
|
}
|
|
138
234
|
break;
|
|
@@ -152,14 +248,24 @@ kind: relation.kind };
|
|
|
152
248
|
break;
|
|
153
249
|
}
|
|
154
250
|
const junctionColumns = columnNames(junction);
|
|
251
|
+
// Junction columns are the ones that actually moved in 0.13:
|
|
252
|
+
// each defaults to its endpoint collection's *slug* run
|
|
253
|
+
// through the foreign-key rule, and slugs are plural.
|
|
254
|
+
const derivedFrom = {
|
|
255
|
+
sourceColumn: [collection.slug],
|
|
256
|
+
targetColumn: [targetCollection.slug]
|
|
257
|
+
} as const;
|
|
155
258
|
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
|
|
156
259
|
if (!junctionColumns.has(column)) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
260
|
+
const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
|
|
261
|
+
defects.push(stale
|
|
262
|
+
? { ...at, ...staleCodegenDefect(table, stale) }
|
|
263
|
+
: {
|
|
264
|
+
...at,
|
|
265
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
266
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
|
|
267
|
+
(label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
268
|
+
});
|
|
163
269
|
}
|
|
164
270
|
}
|
|
165
271
|
break;
|
|
@@ -272,9 +378,25 @@ export function assertRelationsResolve(
|
|
|
272
378
|
);
|
|
273
379
|
|
|
274
380
|
throw new Error(
|
|
275
|
-
`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against
|
|
381
|
+
`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against ` +
|
|
382
|
+
"`backend/src/schema.generated.ts`.\n\n" +
|
|
276
383
|
"Each of these would return no rows at query time rather than reporting an error, " +
|
|
277
384
|
"so they are fatal at boot instead.\n\n" +
|
|
385
|
+
// This reads the *generated file*, not the database, and the difference
|
|
386
|
+
// is the whole diagnosis after an upgrade. Boot-ensure renames columns
|
|
387
|
+
// in the database — a 0.12 → 0.13 upgrade singularises a junction key,
|
|
388
|
+
// `categorie_id` → `category_id` — and the checked-in file still
|
|
389
|
+
// declares the old name. The config is then correct and the file is
|
|
390
|
+
// stale, so the per-defect advice below, which lists the columns this
|
|
391
|
+
// file has, names a column that no longer exists in the database.
|
|
392
|
+
// Following it turns a recoverable state into a broken config.
|
|
393
|
+
//
|
|
394
|
+
// Hence the ordering: regenerate first, and only then consider that the
|
|
395
|
+
// collection might be the thing that is wrong.
|
|
396
|
+
"If the database was migrated recently — an upgrade, a `db push`, a restore — this file is\n" +
|
|
397
|
+
"probably older than the schema it describes. Regenerate it before changing anything else:\n\n" +
|
|
398
|
+
" rebase schema generate\n\n" +
|
|
399
|
+
"If it is already current, then the collection is what disagrees with it:\n\n" +
|
|
278
400
|
lines.join("\n\n") + "\n"
|
|
279
401
|
);
|
|
280
402
|
}
|