@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-CD8o_1Sl.js +8999 -0
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- 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-ViG8XiPn.js +57 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
- package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
- package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
- package/dist/index.es.js +1231 -10031
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
- 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/services/FetchService.d.ts +4 -1
- 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 +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.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-B2LsrINK.js +530 -0
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- 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 +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/auth-schema.ts +30 -19
- 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 +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +42 -6
- package/src/schema/generate-postgres-ddl-logic.ts +382 -19
- 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/security/policy-drift.test.ts +11 -3
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +90 -14
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- 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/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
package/src/cli.ts
CHANGED
|
@@ -6,17 +6,19 @@ import fs from "fs";
|
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { logger } from "@rebasepro/server";
|
|
8
8
|
import {
|
|
9
|
+
diagnoseMissingBin,
|
|
9
10
|
resolveLocalBin,
|
|
10
11
|
getTableIncludes,
|
|
11
12
|
getDevDatabaseUrl,
|
|
12
13
|
ensureDevDatabaseExists,
|
|
13
14
|
getTableExcludes,
|
|
14
|
-
ExcludeIntrospectionError
|
|
15
|
+
ExcludeIntrospectionError,
|
|
16
|
+
promptConfirm
|
|
15
17
|
} from "./cli-helpers";
|
|
16
18
|
import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
|
|
19
|
+
import { forLibpq } from "./utils/connection-string";
|
|
17
20
|
import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
|
|
18
21
|
import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
|
|
19
|
-
import readline from "readline";
|
|
20
22
|
|
|
21
23
|
const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
24
|
|
|
@@ -610,23 +612,6 @@ function timeAgo(date: Date): string {
|
|
|
610
612
|
|
|
611
613
|
|
|
612
614
|
|
|
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
615
|
async function runAtlas(
|
|
631
616
|
domain: "schema" | "migrate",
|
|
632
617
|
args: string[],
|
|
@@ -635,9 +620,36 @@ async function runAtlas(
|
|
|
635
620
|
): Promise<string> {
|
|
636
621
|
const atlasBin = resolveLocalBin("atlas");
|
|
637
622
|
if (!atlasBin) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
623
|
+
// Two very different causes, and the advice for one is a loop for the
|
|
624
|
+
// other — see `diagnoseMissingBin`. This used to say "Install it with:
|
|
625
|
+
// pnpm add -D @ariga/atlas" unconditionally, which is the exact command
|
|
626
|
+
// that produces the far more common of the two states.
|
|
627
|
+
logger.error(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
|
|
628
|
+
|
|
629
|
+
if (diagnoseMissingBin("@ariga/atlas") === "build-script-blocked") {
|
|
630
|
+
logger.error(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
|
|
631
|
+
logger.error(chalk.gray(
|
|
632
|
+
" It downloads that binary in a `preinstall` script, and pnpm 10+ does not\n" +
|
|
633
|
+
" run a dependency's scripts unless you allow it. The install still exits 0,\n" +
|
|
634
|
+
" so the only sign is `Ignored build scripts: @ariga/atlas` in its output.\n"
|
|
635
|
+
));
|
|
636
|
+
logger.error(" Fix it with either:\n");
|
|
637
|
+
logger.error(chalk.bold(" pnpm approve-builds\n"));
|
|
638
|
+
logger.error(" or, to record it in the project (what `rebase init` scaffolds):\n");
|
|
639
|
+
logger.error(chalk.bold(
|
|
640
|
+
" // package.json\n" +
|
|
641
|
+
" \"pnpm\": { \"onlyBuiltDependencies\": [\"@ariga/atlas\"] }\n"
|
|
642
|
+
));
|
|
643
|
+
logger.error(chalk.gray(" Then re-run `pnpm install`.\n"));
|
|
644
|
+
} else {
|
|
645
|
+
logger.error(chalk.gray(" It is not installed in this project.\n"));
|
|
646
|
+
logger.error(" Install it with:\n");
|
|
647
|
+
logger.error(chalk.bold(" pnpm add -D @ariga/atlas\n"));
|
|
648
|
+
logger.error(chalk.gray(
|
|
649
|
+
" If pnpm then reports `Ignored build scripts`, also run `pnpm approve-builds` —\n" +
|
|
650
|
+
" the package carries a `preinstall` script that fetches the binary.\n"
|
|
651
|
+
));
|
|
652
|
+
}
|
|
641
653
|
process.exit(1);
|
|
642
654
|
}
|
|
643
655
|
|
|
@@ -682,19 +694,25 @@ async function runAtlas(
|
|
|
682
694
|
const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
|
|
683
695
|
await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
|
|
684
696
|
|
|
697
|
+
// Atlas speaks libpq, which rejects the `sslmode=no-verify` that
|
|
698
|
+
// node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
|
|
699
|
+
// everything above still connects with the URL as configured.
|
|
700
|
+
const atlasUrl = forLibpq(databaseUrl);
|
|
701
|
+
const atlasDevUrl = forLibpq(devDatabaseUrl);
|
|
702
|
+
|
|
685
703
|
const atlasArgs = [domain, ...args];
|
|
686
704
|
|
|
687
705
|
if (domain === "schema") {
|
|
688
706
|
if (args.includes("apply")) {
|
|
689
|
-
atlasArgs.push("--url",
|
|
707
|
+
atlasArgs.push("--url", atlasUrl, "--dev-url", atlasDevUrl);
|
|
690
708
|
} else if (args.includes("clean") || args.includes("inspect")) {
|
|
691
|
-
atlasArgs.push("--url",
|
|
709
|
+
atlasArgs.push("--url", atlasUrl);
|
|
692
710
|
}
|
|
693
711
|
} else if (domain === "migrate") {
|
|
694
712
|
if (args.includes("diff")) {
|
|
695
|
-
atlasArgs.push("--dev-url",
|
|
713
|
+
atlasArgs.push("--dev-url", atlasDevUrl);
|
|
696
714
|
} else if (args.includes("apply") || args.includes("status")) {
|
|
697
|
-
atlasArgs.push("--url",
|
|
715
|
+
atlasArgs.push("--url", atlasUrl, "--revisions-schema", "rebase");
|
|
698
716
|
if (args.includes("apply")) {
|
|
699
717
|
atlasArgs.push("--allow-dirty");
|
|
700
718
|
}
|
|
@@ -135,6 +135,21 @@ kind: relation.kind };
|
|
|
135
135
|
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
|
+
// `sourceKey` is the easiest of the two to put on the wrong
|
|
139
|
+
// side — it is the only column in a `hasMany` that lives
|
|
140
|
+
// here rather than on the target, and naming a target column
|
|
141
|
+
// reads perfectly well right next to `foreignKeyOnTarget`.
|
|
142
|
+
if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) {
|
|
143
|
+
defects.push({
|
|
144
|
+
...at,
|
|
145
|
+
problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
|
|
146
|
+
fix: targetColumns.has(relation.sourceKey)
|
|
147
|
+
? `it is a column on the *target* table \`${targetTableName}\` — \`sourceKey\` names ` +
|
|
148
|
+
"the column on this collection that the target's foreign key points at, so it " +
|
|
149
|
+
`must be one of: ${quote(sourceColumns)}`
|
|
150
|
+
: `add the column, or set \`sourceKey\` to one of: ${quote(sourceColumns)}`
|
|
151
|
+
});
|
|
152
|
+
}
|
|
138
153
|
break;
|
|
139
154
|
}
|
|
140
155
|
|
package/src/connection.ts
CHANGED
|
@@ -21,10 +21,19 @@ export interface PostgresPoolConfig {
|
|
|
21
21
|
statementTimeout?: number;
|
|
22
22
|
/** Enable TCP keep-alive (default: true) */
|
|
23
23
|
keepAlive?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* `search_path` pinned on every connection (default: `"public"`).
|
|
26
|
+
*
|
|
27
|
+
* Pass `false` to send no `search_path` at all and inherit whatever the
|
|
28
|
+
* server/role defaults to. See {@link pinSearchPath} for why the default
|
|
29
|
+
* is not "inherit".
|
|
30
|
+
*/
|
|
31
|
+
searchPath?: string | false;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
27
35
|
max: 20,
|
|
36
|
+
searchPath: "public",
|
|
28
37
|
idleTimeoutMillis: 30_000,
|
|
29
38
|
connectionTimeoutMillis: 10_000,
|
|
30
39
|
// The client-side read timeout MUST be comfortably above the server-side
|
|
@@ -43,6 +52,67 @@ const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
|
43
52
|
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
44
53
|
const TX_IDLE = "I";
|
|
45
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Pin `search_path` into a connection string, so unqualified SQL resolves to a
|
|
57
|
+
* schema this framework chose rather than to one Postgres inferred.
|
|
58
|
+
*
|
|
59
|
+
* Postgres defaults `search_path` to `"$user", public`: the *first* candidate
|
|
60
|
+
* is a schema named after the connecting role. Rebase creates a schema called
|
|
61
|
+
* `rebase` (auth, history, api keys), and every template, compose file and
|
|
62
|
+
* deployment doc names the database role `rebase` too — so `$user` resolves to
|
|
63
|
+
* a schema that exists, and every unqualified statement lands there instead of
|
|
64
|
+
* in `public`. The generated Drizzle schema emits bare `pgTable("posts", …)`
|
|
65
|
+
* for any collection without an explicit `schema`, which makes the *runtime's*
|
|
66
|
+
* own reads and writes unqualified; a developer's raw `rebase.sql(...)`, the
|
|
67
|
+
* Studio SQL editor and any hand-written migration are unqualified too. The
|
|
68
|
+
* result is collection tables created in, and served from, `rebase`.
|
|
69
|
+
*
|
|
70
|
+
* Drizzle cannot express the fix on its side: `pgSchema("public")` throws by
|
|
71
|
+
* design ("just use pgTable() instead"), so there is no way to emit a
|
|
72
|
+
* public-qualified table from the generator. The pin has to live on the
|
|
73
|
+
* connection.
|
|
74
|
+
*
|
|
75
|
+
* Precedence is deliberate and verified against node-postgres: `options` in
|
|
76
|
+
* the connection string wins over the `options` field passed to `Pool`, so
|
|
77
|
+
* rewriting the URL — rather than setting the field — is what makes this
|
|
78
|
+
* authoritative. Two escape hatches survive it:
|
|
79
|
+
*
|
|
80
|
+
* - an `options` that already mentions `search_path` is left untouched, so a
|
|
81
|
+
* deployment that deliberately pins something else keeps it;
|
|
82
|
+
* - `searchPath: false` (or an unparseable, non-URL connection string) sends
|
|
83
|
+
* nothing and inherits the server default.
|
|
84
|
+
*
|
|
85
|
+
* Anything else in `options` (a `statement_timeout`, say) is preserved and the
|
|
86
|
+
* `search_path` flag is appended to it.
|
|
87
|
+
*/
|
|
88
|
+
export function pinSearchPath(connectionString: string, searchPath: string | false = "public"): string {
|
|
89
|
+
if (searchPath === false) return connectionString;
|
|
90
|
+
|
|
91
|
+
let url: URL;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(connectionString);
|
|
94
|
+
} catch {
|
|
95
|
+
// Key/value DSNs and anything else we cannot parse are returned as
|
|
96
|
+
// given: a connection that works unpinned beats one we corrupted.
|
|
97
|
+
return connectionString;
|
|
98
|
+
}
|
|
99
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
|
|
100
|
+
|
|
101
|
+
const existing = url.searchParams.get("options");
|
|
102
|
+
if (existing && /(^|\s)-c\s*search_path\s*=/.test(existing)) return connectionString;
|
|
103
|
+
|
|
104
|
+
const flag = `-c search_path=${searchPath}`;
|
|
105
|
+
url.searchParams.set("options", existing ? `${existing} ${flag}` : flag);
|
|
106
|
+
// Re-serialize by hand. `URLSearchParams` writes a space as `+`, which
|
|
107
|
+
// node-postgres happens to decode but libpq does not — and this same string
|
|
108
|
+
// is handed to `pg_dump`/`psql` for backups. Percent-encoding is the form
|
|
109
|
+
// both agree on, and is what the scaffolded `.env` already ships.
|
|
110
|
+
url.search = Array.from(url.searchParams.entries())
|
|
111
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
112
|
+
.join("&");
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
46
116
|
/**
|
|
47
117
|
* Destroy pool clients that are released while still inside a transaction.
|
|
48
118
|
*
|
|
@@ -108,6 +178,7 @@ export function createPostgresDatabaseConnection(
|
|
|
108
178
|
) {
|
|
109
179
|
const opts = { ...DEFAULT_POOL,
|
|
110
180
|
...poolConfig };
|
|
181
|
+
connectionString = pinSearchPath(connectionString, opts.searchPath);
|
|
111
182
|
|
|
112
183
|
const pgPoolConfig: PoolConfig = {
|
|
113
184
|
connectionString,
|
|
@@ -159,6 +230,7 @@ export function createDirectDatabaseConnection(
|
|
|
159
230
|
max: 5,
|
|
160
231
|
...poolConfig
|
|
161
232
|
};
|
|
233
|
+
connectionString = pinSearchPath(connectionString, opts.searchPath);
|
|
162
234
|
|
|
163
235
|
const pgPoolConfig: PoolConfig = {
|
|
164
236
|
connectionString,
|
|
@@ -199,6 +271,7 @@ export function createReadReplicaConnection(
|
|
|
199
271
|
max: 10,
|
|
200
272
|
...poolConfig
|
|
201
273
|
};
|
|
274
|
+
connectionString = pinSearchPath(connectionString, opts.searchPath);
|
|
202
275
|
|
|
203
276
|
const pgPoolConfig: PoolConfig = {
|
|
204
277
|
connectionString,
|
package/src/data-transformer.ts
CHANGED
|
@@ -328,9 +328,15 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
328
328
|
const targetCollection = relation.target();
|
|
329
329
|
const targetTable = registry.getTable(getTableName(targetCollection));
|
|
330
330
|
const pks = getPrimaryKeys(collection, registry!);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
// What the target's foreign key holds. Ordinarily this
|
|
332
|
+
// row's id; for a link on a natural key, the value of
|
|
333
|
+
// the column it points at — which is on the row already,
|
|
334
|
+
// so this costs nothing extra.
|
|
335
|
+
const currentId = relation.sourceKey
|
|
336
|
+
? (data as Record<string, unknown>)[relation.sourceKey] as string | number | undefined
|
|
337
|
+
: buildCompositeId(data, pks);
|
|
338
|
+
|
|
339
|
+
if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
|
|
334
340
|
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
335
341
|
if (foreignKeyColumn) {
|
|
336
342
|
// Query the target table to find row that references this row
|
|
@@ -2,7 +2,7 @@ import { Pool } from "pg";
|
|
|
2
2
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { logger } from "@rebasepro/server";
|
|
5
|
-
import { guardPoolAgainstDirtyRelease } from "./connection";
|
|
5
|
+
import { guardPoolAgainstDirtyRelease, pinSearchPath } from "./connection";
|
|
6
6
|
|
|
7
7
|
export class DatabasePoolManager {
|
|
8
8
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -41,7 +41,10 @@ export class DatabasePoolManager {
|
|
|
41
41
|
url.pathname = `/${databaseName}`;
|
|
42
42
|
|
|
43
43
|
const pool = new Pool({
|
|
44
|
-
|
|
44
|
+
// Same pin as the primary pool: these are branch/multi-database
|
|
45
|
+
// connections to the *same* server, so they inherit the same
|
|
46
|
+
// `"$user"` hazard. See `pinSearchPath`.
|
|
47
|
+
connectionString: pinSearchPath(url.toString()),
|
|
45
48
|
max: 10, // Default sensible limit, can be tuned later
|
|
46
49
|
idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
|
|
47
50
|
allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { pgSchema, pgTable,
|
|
1
|
+
import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
6
|
+
*
|
|
7
|
+
* This module builds queries; it does not create tables. `ensureAuthTablesExist`
|
|
8
|
+
* owns the DDL, which makes everything here a *claim* about a database it cannot
|
|
9
|
+
* enforce — and the claims drifted. Every column below was declared
|
|
10
|
+
* `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
|
|
11
|
+
* `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
|
|
12
|
+
* `token_hash` as varchar(255). None of it was true of any database this
|
|
13
|
+
* framework ever provisioned. Harmless at runtime — drizzle does not enforce a
|
|
14
|
+
* length client-side, so the widths only ever misled the next reader — but a
|
|
15
|
+
* schema module that describes columns that do not exist is worse than no
|
|
16
|
+
* schema module. They are `text` here now because they are TEXT there.
|
|
6
17
|
*/
|
|
7
18
|
export function createAuthSchema(usersSchemaName = "rebase") {
|
|
8
19
|
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
15
26
|
*/
|
|
16
27
|
const users = usersTableCreator("users", {
|
|
17
28
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
18
|
-
email:
|
|
19
|
-
passwordHash:
|
|
20
|
-
displayName:
|
|
21
|
-
photoUrl:
|
|
29
|
+
email: text("email").notNull().unique(),
|
|
30
|
+
passwordHash: text("password_hash"), // NULL for OAuth-only users
|
|
31
|
+
displayName: text("display_name"),
|
|
32
|
+
photoUrl: text("photo_url"),
|
|
22
33
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
23
|
-
emailVerificationToken:
|
|
34
|
+
emailVerificationToken: text("email_verification_token"),
|
|
24
35
|
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
25
36
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
26
37
|
roles: text("roles").array().default([]).notNull(),
|
|
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
65
76
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
66
77
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
78
|
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
68
|
-
tokenHash:
|
|
79
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
69
80
|
expiresAt: timestamp("expires_at").notNull(),
|
|
70
81
|
revoked: boolean("revoked").default(false).notNull(),
|
|
71
82
|
rotatedAt: timestamp("rotated_at"),
|
|
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
76
87
|
* that rotates immediately after it.
|
|
77
88
|
*/
|
|
78
89
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
79
|
-
userAgent:
|
|
80
|
-
ipAddress:
|
|
90
|
+
userAgent: text("user_agent"),
|
|
91
|
+
ipAddress: text("ip_address"),
|
|
81
92
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
82
93
|
}, (table) => ({
|
|
83
94
|
sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
|
|
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
89
100
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
90
101
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
91
102
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
92
|
-
tokenHash:
|
|
103
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
93
104
|
expiresAt: timestamp("expires_at").notNull(),
|
|
94
105
|
usedAt: timestamp("used_at"),
|
|
95
106
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
99
110
|
* App config - key/value store for custom settings
|
|
100
111
|
*/
|
|
101
112
|
const appConfig = tableCreator("app_config", {
|
|
102
|
-
key:
|
|
113
|
+
key: text("key").primaryKey(),
|
|
103
114
|
value: jsonb("value").notNull(),
|
|
104
115
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
105
116
|
});
|
|
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
110
121
|
const userIdentities = tableCreator("user_identities", {
|
|
111
122
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
112
123
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
113
|
-
provider:
|
|
114
|
-
providerId:
|
|
124
|
+
provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
|
|
125
|
+
providerId: text("provider_id").notNull(),
|
|
115
126
|
profileData: jsonb("profile_data"),
|
|
116
127
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
117
128
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
125
136
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
126
137
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
127
138
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
128
|
-
factorType:
|
|
129
|
-
secretEncrypted:
|
|
130
|
-
friendlyName:
|
|
139
|
+
factorType: text("factor_type").notNull(), // 'totp'
|
|
140
|
+
secretEncrypted: text("secret_encrypted").notNull(),
|
|
141
|
+
friendlyName: text("friendly_name"),
|
|
131
142
|
verified: boolean("verified").default(false).notNull(),
|
|
132
143
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
133
144
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
141
152
|
factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
|
|
142
153
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
143
154
|
verifiedAt: timestamp("verified_at"),
|
|
144
|
-
ipAddress:
|
|
155
|
+
ipAddress: text("ip_address"),
|
|
145
156
|
expiresAt: timestamp("expires_at").notNull()
|
|
146
157
|
});
|
|
147
158
|
|
|
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
151
162
|
const recoveryCodes = tableCreator("recovery_codes", {
|
|
152
163
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
153
164
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
154
|
-
codeHash:
|
|
165
|
+
codeHash: text("code_hash").notNull(),
|
|
155
166
|
usedAt: timestamp("used_at"),
|
|
156
167
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
157
168
|
});
|
|
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
162
173
|
const magicLinkTokens = tableCreator("magic_link_tokens", {
|
|
163
174
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
164
175
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
165
|
-
tokenHash:
|
|
176
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
166
177
|
expiresAt: timestamp("expires_at").notNull(),
|
|
167
178
|
usedAt: timestamp("used_at"),
|
|
168
179
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying a bundle's RLS policies to a database at boot, idempotently.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* {@link ensureCollectionTables} creates the collection *tables* a managed
|
|
7
|
+
* runtime boots against, but a table with row-level security disabled and no
|
|
8
|
+
* policies is not servable: authenticated requests run as the restricted
|
|
9
|
+
* `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
|
|
10
|
+
* public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
|
|
11
|
+
* is denied. The policies live in the collections' `securityRules`; nothing at
|
|
12
|
+
* boot applied them. `rebase db push` does — but it drives Atlas against a
|
|
13
|
+
* local `DATABASE_URL`, and a managed tenant's database is reachable only from
|
|
14
|
+
* inside the cluster, by the runtime that is already connected to it. So the
|
|
15
|
+
* runtime is the only thing that *can* apply them, and this is where it does.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this is safe to run on every boot
|
|
18
|
+
*
|
|
19
|
+
* Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
|
|
20
|
+
* enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
|
|
21
|
+
* a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
|
|
22
|
+
* and replaces; it never drops data. (It does not *reconcile* — a policy a
|
|
23
|
+
* previous push left behind under an old name is not removed here; that stays a
|
|
24
|
+
* `db push` / `db migrate` concern, alongside destructive schema changes.)
|
|
25
|
+
*
|
|
26
|
+
* Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
|
|
27
|
+
* table whose policies could not be applied fails **closed** (denies) rather
|
|
28
|
+
* than leaking rows. One collection's policy failing (e.g. a rule that
|
|
29
|
+
* references a table a real migration has not created yet) must not crash-loop
|
|
30
|
+
* the whole deployment and take the other collections' working routes down with
|
|
31
|
+
* it. Failures are reported loudly and per-table so the operator can see
|
|
32
|
+
* exactly which collection is not yet servable and why.
|
|
33
|
+
*/
|
|
34
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
+
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
37
|
+
|
|
38
|
+
export interface PolicyEnsureResult {
|
|
39
|
+
/** `CREATE POLICY` statements that ran successfully. */
|
|
40
|
+
policiesApplied: number;
|
|
41
|
+
/** Tables that had RLS enabled. */
|
|
42
|
+
tablesSecured: number;
|
|
43
|
+
/** Declared tables absent from the database — left to a real migration. */
|
|
44
|
+
skipped: { table: string; reason: string }[];
|
|
45
|
+
/** Tables whose RLS could not be fully applied (fail closed). */
|
|
46
|
+
failures: { table: string; error: string }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bring the declared collections' RLS policies up to date. Returns what it did.
|
|
53
|
+
*
|
|
54
|
+
* Only tables that already exist are touched: the boot-time table creator runs
|
|
55
|
+
* first, so anything still missing is a table this additive path is not allowed
|
|
56
|
+
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
|
+
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureCollectionPolicies(
|
|
60
|
+
client: Queryable,
|
|
61
|
+
collections: CollectionConfig[],
|
|
62
|
+
log?: (message: string) => void
|
|
63
|
+
): Promise<PolicyEnsureResult> {
|
|
64
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
65
|
+
|
|
66
|
+
const plans = planCollectionPolicies(collections);
|
|
67
|
+
if (plans.length === 0) return result;
|
|
68
|
+
|
|
69
|
+
const schemas = Array.from(new Set(plans.map(p => p.schema)));
|
|
70
|
+
const existing = await readExistingSchema(client, schemas);
|
|
71
|
+
|
|
72
|
+
for (const plan of plans) {
|
|
73
|
+
if (!existing.tables.has(plan.qualified)) {
|
|
74
|
+
result.skipped.push({
|
|
75
|
+
table: plan.qualified,
|
|
76
|
+
reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Enable first: if a later policy statement fails, the table is left
|
|
83
|
+
// locked (deny-all for the user role) rather than open.
|
|
84
|
+
await client.query(plan.enableRls);
|
|
85
|
+
result.tablesSecured++;
|
|
86
|
+
|
|
87
|
+
let created = 0;
|
|
88
|
+
for (const statement of plan.policyStatements) {
|
|
89
|
+
await client.query(statement);
|
|
90
|
+
if (isCreatePolicy(statement)) {
|
|
91
|
+
result.policiesApplied++;
|
|
92
|
+
created++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
result.failures.push({
|
|
98
|
+
table: plan.qualified,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
|
|
|
87
87
|
expect(sql).not.toMatch(/NOT NULL/i);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
// ── Relations ────────────────────────────────────────────────────────────
|
|
91
|
+
//
|
|
92
|
+
// These columns were once skipped outright, on the reasoning that a bare
|
|
93
|
+
// column without its foreign key would disagree with what `db push` later
|
|
94
|
+
// generated. On a managed tenant nothing pushes afterwards, so the table
|
|
95
|
+
// arrived without the column its own collection reads: every insert 400ed
|
|
96
|
+
// with `column "author_id" does not exist`. The answer is to emit the key
|
|
97
|
+
// too, from the same planner `db push` uses.
|
|
98
|
+
|
|
99
|
+
const authors = {
|
|
100
|
+
name: "Authors",
|
|
101
|
+
slug: "authors",
|
|
102
|
+
properties: {
|
|
103
|
+
id: { name: "ID", type: "string", isId: "uuid" },
|
|
104
|
+
name: { name: "Name", type: "string" }
|
|
105
|
+
}
|
|
106
|
+
} as unknown as CollectionConfig;
|
|
107
|
+
|
|
108
|
+
const postsWithAuthor = {
|
|
109
|
+
...posts,
|
|
110
|
+
properties: {
|
|
111
|
+
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
112
|
+
author: { name: "Author", type: "reference", path: "authors" }
|
|
113
|
+
}
|
|
114
|
+
} as unknown as CollectionConfig;
|
|
115
|
+
|
|
116
|
+
const tags = {
|
|
117
|
+
name: "Tags",
|
|
118
|
+
slug: "tags",
|
|
119
|
+
properties: {
|
|
120
|
+
id: { name: "ID", type: "number", isId: "increment" },
|
|
121
|
+
name: { name: "Name", type: "string" }
|
|
122
|
+
}
|
|
123
|
+
} as unknown as CollectionConfig;
|
|
124
|
+
|
|
125
|
+
const postsWithTags = {
|
|
126
|
+
...posts,
|
|
127
|
+
properties: {
|
|
128
|
+
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
129
|
+
tags: {
|
|
130
|
+
name: "Tags",
|
|
131
|
+
type: "relation",
|
|
132
|
+
relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
|
|
96
133
|
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
}
|
|
135
|
+
} as unknown as CollectionConfig;
|
|
136
|
+
|
|
137
|
+
it("adds a reference column, and the foreign key that makes it one", () => {
|
|
138
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
|
|
139
|
+
|
|
140
|
+
expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
|
|
141
|
+
const fk = plan.actions.find(a => a.kind === "add-constraint");
|
|
142
|
+
expect(fk?.target).toBe("public.posts.posts_author_fkey");
|
|
143
|
+
expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("orders every constraint after the columns and tables it depends on", () => {
|
|
147
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
|
|
148
|
+
const kinds = plan.actions.map(a => a.kind);
|
|
149
|
+
expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
150
|
+
expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
|
|
154
|
+
const existing: ExistingSchema = {
|
|
155
|
+
tables: new Map([
|
|
156
|
+
["public.posts", new Set(["id", "author"])],
|
|
157
|
+
["public.authors", new Set(["id", "name"])]
|
|
158
|
+
]),
|
|
159
|
+
enums: new Set(["public.posts_status"]),
|
|
160
|
+
constraints: new Set(["public.posts.posts_author_fkey"])
|
|
161
|
+
};
|
|
162
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
|
|
163
|
+
expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
|
|
167
|
+
const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
|
|
168
|
+
const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
|
|
169
|
+
|
|
170
|
+
expect(junction).toBeDefined();
|
|
171
|
+
// The endpoint key types have to match the primary keys they reference:
|
|
172
|
+
// posts is a uuid, tags an auto-increment integer.
|
|
173
|
+
expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
|
|
174
|
+
expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
|
|
175
|
+
expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
|
|
176
|
+
|
|
177
|
+
const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
|
|
178
|
+
expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
|
|
179
|
+
expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("leaves an existing junction table alone", () => {
|
|
183
|
+
const existing: ExistingSchema = {
|
|
184
|
+
tables: new Map([
|
|
185
|
+
["public.posts", new Set(["id", "title", "views", "status"])],
|
|
186
|
+
["public.tags", new Set(["id", "name"])],
|
|
187
|
+
["public.posts_tags", new Set(["post_id", "tag_id"])]
|
|
188
|
+
]),
|
|
189
|
+
enums: new Set(["public.posts_status"]),
|
|
190
|
+
constraints: new Set([
|
|
191
|
+
"public.posts_tags.posts_tags_post_id_fkey",
|
|
192
|
+
"public.posts_tags.posts_tags_tag_id_fkey"
|
|
193
|
+
])
|
|
194
|
+
};
|
|
195
|
+
expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
|
|
100
196
|
});
|
|
101
197
|
});
|
|
102
198
|
|