@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g06f263c
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 +25 -1
- package/dist/auth/services.d.ts +16 -0
- package/dist/backup-service-vAKWJkYL.js +8867 -0
- package/dist/backup-service-vAKWJkYL.js.map +1 -0
- package/dist/cli-helpers.d.ts +24 -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-BjSwj0FM.js +57 -0
- package/dist/ensure-collection-policies-BjSwj0FM.js.map +1 -0
- package/dist/ensure-collection-tables-D6-XhqnQ.js +590 -0
- package/dist/ensure-collection-tables-D6-XhqnQ.js.map +1 -0
- package/dist/index.es.js +545 -9629
- 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 +24 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +116 -1
- package/dist/{src-BbFOPJ1S.js → src-C_NHNVW2.js} +94 -153
- package/dist/src-C_NHNVW2.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/pg-error-utils.d.ts +19 -0
- package/dist/websocket-DB7TbFPT.js +529 -0
- package/dist/websocket-DB7TbFPT.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +14 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +21 -2
- package/src/cli-helpers.ts +44 -0
- package/src/cli.ts +31 -3
- package/src/connection.ts +73 -0
- 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 +142 -25
- package/src/schema/generate-drizzle-schema-logic.ts +16 -5
- package/src/schema/generate-postgres-ddl-logic.ts +335 -16
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/services/RelationService.ts +38 -3
- package/src/services/realtimeService.ts +3 -3
- package/src/utils/pg-error-utils.ts +46 -0
- package/src/websocket.ts +3 -3
- 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
|
@@ -107,16 +107,38 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
107
107
|
? "GENERATED ALWAYS AS IDENTITY"
|
|
108
108
|
: "DEFAULT gen_random_uuid()::text";
|
|
109
109
|
|
|
110
|
+
// Identifiers for the constraint and indexes reconciled further down.
|
|
111
|
+
// Derived from the resolved table name so two auth tables in different
|
|
112
|
+
// schemas cannot collide, and truncated to Postgres's 63-byte identifier
|
|
113
|
+
// limit here rather than letting the server truncate silently — the
|
|
114
|
+
// `IF NOT EXISTS` guards below have to compare against the same name
|
|
115
|
+
// Postgres actually stored, or they re-run forever.
|
|
116
|
+
const authIdentifier = (suffix: string) => `${resolvedTable}_${suffix}`.slice(0, 63);
|
|
117
|
+
const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
|
|
118
|
+
const emailLowerUniqueIndex = authIdentifier("email_lower_key");
|
|
119
|
+
const verificationTokenIndex = authIdentifier("email_verification_token_idx");
|
|
120
|
+
|
|
121
|
+
// Every string column here is TEXT, deliberately. In Postgres VARCHAR(n)
|
|
122
|
+
// and TEXT are the same type with the same storage and the same
|
|
123
|
+
// performance; the only difference is a length check, and none of these
|
|
124
|
+
// columns wants one. The widths this table used to carry were inherited
|
|
125
|
+
// MySQL habit (255) and they were all wrong in the same direction —
|
|
126
|
+
// `password_hash VARCHAR(255)` against a 193-char scrypt string left 62
|
|
127
|
+
// characters of headroom in front of a KEY_LENGTH constant living in
|
|
128
|
+
// another package, and `photo_url VARCHAR(500)` rejected the `data:` URIs
|
|
129
|
+
// and long signed URLs that OAuth providers hand back. A limit worth
|
|
130
|
+
// having is a CHECK — alterable without a table rewrite, unlike a type
|
|
131
|
+
// modifier — which is why `email` has one and nothing else does.
|
|
110
132
|
await db.execute(sql`
|
|
111
133
|
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
112
134
|
id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
|
|
113
|
-
email
|
|
114
|
-
display_name
|
|
115
|
-
photo_url
|
|
135
|
+
email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
|
|
136
|
+
display_name TEXT,
|
|
137
|
+
photo_url TEXT,
|
|
116
138
|
roles TEXT[] DEFAULT '{}' NOT NULL,
|
|
117
|
-
password_hash
|
|
139
|
+
password_hash TEXT,
|
|
118
140
|
email_verified BOOLEAN DEFAULT FALSE NOT NULL,
|
|
119
|
-
email_verification_token
|
|
141
|
+
email_verification_token TEXT,
|
|
120
142
|
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
121
143
|
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
122
144
|
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
@@ -359,12 +381,12 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
359
381
|
// statement that references it. `email` is deliberately absent: it has
|
|
360
382
|
// existed since the first era and cannot be added NOT NULL safely.
|
|
361
383
|
const userColumnBackfills = [
|
|
362
|
-
"display_name
|
|
363
|
-
"photo_url
|
|
384
|
+
"display_name TEXT",
|
|
385
|
+
"photo_url TEXT",
|
|
364
386
|
"roles TEXT[] DEFAULT '{}' NOT NULL",
|
|
365
|
-
"password_hash
|
|
387
|
+
"password_hash TEXT",
|
|
366
388
|
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
367
|
-
"email_verification_token
|
|
389
|
+
"email_verification_token TEXT",
|
|
368
390
|
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
369
391
|
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
370
392
|
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
@@ -379,6 +401,139 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
379
401
|
`);
|
|
380
402
|
}
|
|
381
403
|
|
|
404
|
+
// Which of the columns below the table actually has. An adopted table —
|
|
405
|
+
// one this framework did not create, which the column-name resolution in
|
|
406
|
+
// `services.ts` exists to support — may be missing any of them, and every
|
|
407
|
+
// statement past this point has to tolerate that rather than abort the
|
|
408
|
+
// whole migration block.
|
|
409
|
+
const usersColumns = await db.execute(sql`
|
|
410
|
+
SELECT column_name, data_type
|
|
411
|
+
FROM information_schema.columns
|
|
412
|
+
WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
|
|
413
|
+
`);
|
|
414
|
+
const usersColumnTypes = new Map(
|
|
415
|
+
(usersColumns.rows as { column_name: string; data_type: string }[])
|
|
416
|
+
.map(row => [row.column_name, row.data_type])
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
|
|
420
|
+
// Tables created before the widths came off still carry them. Postgres
|
|
421
|
+
// treats varchar(n) → text as binary-coercible with no stricter
|
|
422
|
+
// constraint, so this is a catalogue-only change: no table rewrite, no
|
|
423
|
+
// index rebuild, just a brief ACCESS EXCLUSIVE lock. Guarded on the
|
|
424
|
+
// current type so it runs once and is a pure catalogue read thereafter.
|
|
425
|
+
for (const column of ["email", "display_name", "photo_url", "password_hash", "email_verification_token"]) {
|
|
426
|
+
if (usersColumnTypes.get(column) !== "character varying") continue;
|
|
427
|
+
await db.execute(sql`
|
|
428
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
429
|
+
ALTER COLUMN ${sql.raw(`"${column}"`)} TYPE TEXT
|
|
430
|
+
`);
|
|
431
|
+
logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Migration: case-insensitive email identity ──────────────────────
|
|
435
|
+
// `getUserByEmail` has always searched `email.toLowerCase()` while the
|
|
436
|
+
// write path stored whatever it was handed, leaving normalisation to a
|
|
437
|
+
// convention every caller had to remember. A row that reached the table
|
|
438
|
+
// with mixed case is then invisible to every lookup — the account exists,
|
|
439
|
+
// login reports no such user, and the plain UNIQUE on `email` does not
|
|
440
|
+
// stop a second row differing only in case, because it compares bytes.
|
|
441
|
+
//
|
|
442
|
+
// Fixed on both sides: `mapPayload` now folds on write, and this index
|
|
443
|
+
// makes the database agree. A unique index on lower(email) is strictly
|
|
444
|
+
// stronger than the byte-exact UNIQUE that older tables carry, so the
|
|
445
|
+
// old constraint is left alone — it can no longer fire on anything the
|
|
446
|
+
// new one would allow.
|
|
447
|
+
//
|
|
448
|
+
// Deliberately no AUTH_SCHEMA_VERSION bump: a runtime that predates this
|
|
449
|
+
// migration keeps working against the migrated table (all of its own
|
|
450
|
+
// write paths already lower-cased), which is exactly the additive case
|
|
451
|
+
// the version stamp is documented not to cover.
|
|
452
|
+
if (usersColumnTypes.has("email")) {
|
|
453
|
+
const indexPresent = await db.execute(sql`
|
|
454
|
+
SELECT 1 FROM pg_class c
|
|
455
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
456
|
+
WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'
|
|
457
|
+
`);
|
|
458
|
+
if (indexPresent.rows.length === 0) {
|
|
459
|
+
// Case-collisions already in the table would make the unique
|
|
460
|
+
// index impossible to build. Report them and leave the table
|
|
461
|
+
// alone: the next boot retries, so fixing the rows is all the
|
|
462
|
+
// operator has to do. Failing loudly beats folding the emails
|
|
463
|
+
// and letting CREATE INDEX pick which account survives.
|
|
464
|
+
const collisions = await db.execute(sql`
|
|
465
|
+
SELECT lower(email) AS normalized, count(*)::int AS occurrences
|
|
466
|
+
FROM ${sql.raw(usersTableName)}
|
|
467
|
+
WHERE email IS NOT NULL
|
|
468
|
+
GROUP BY lower(email)
|
|
469
|
+
HAVING count(*) > 1
|
|
470
|
+
LIMIT 10
|
|
471
|
+
`);
|
|
472
|
+
if (collisions.rows.length > 0) {
|
|
473
|
+
const sample = (collisions.rows as { normalized: string; occurrences: number }[])
|
|
474
|
+
.map(row => `${row.normalized} (×${row.occurrences})`)
|
|
475
|
+
.join(", ");
|
|
476
|
+
logger.error(
|
|
477
|
+
`❌ Cannot enforce case-insensitive email uniqueness on ${usersTableName}: ` +
|
|
478
|
+
`these addresses already exist more than once, differing only in case — ${sample}. ` +
|
|
479
|
+
"Merge or delete the duplicates and restart; until then two accounts can share " +
|
|
480
|
+
"one address and only the lower-cased one is reachable by login."
|
|
481
|
+
);
|
|
482
|
+
} else {
|
|
483
|
+
const folded = await db.execute(sql`
|
|
484
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
485
|
+
SET email = lower(email)
|
|
486
|
+
WHERE email IS NOT NULL AND email <> lower(email)
|
|
487
|
+
`);
|
|
488
|
+
if (folded.rowCount) {
|
|
489
|
+
logger.info(`🔧 Lower-cased ${folded.rowCount} email address(es) in ${usersTableName}`);
|
|
490
|
+
}
|
|
491
|
+
await db.execute(sql`
|
|
492
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${sql.raw(`"${emailLowerUniqueIndex}"`)}
|
|
493
|
+
ON ${sql.raw(usersTableName)} (lower(email))
|
|
494
|
+
`);
|
|
495
|
+
logger.info(`✅ Email uniqueness on ${usersTableName} is now case-insensitive`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Migration: bound the email column's length ──────────────────────
|
|
501
|
+
// The only length limit on this table worth keeping. 320 is the RFC 5321
|
|
502
|
+
// maximum (64-char local part + @ + 255-char domain), and it matters here
|
|
503
|
+
// beyond tidiness: `email` carries a btree index, and a sufficiently long
|
|
504
|
+
// value fails index insertion with an error that says nothing about
|
|
505
|
+
// email. NOT VALID so an adopted table with a long row still migrates —
|
|
506
|
+
// it binds all new writes, which is the part that matters.
|
|
507
|
+
if (usersColumnTypes.has("email")) {
|
|
508
|
+
const checkPresent = await db.execute(sql`
|
|
509
|
+
SELECT 1 FROM pg_constraint c
|
|
510
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
511
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
512
|
+
WHERE n.nspname = ${usersSchema}
|
|
513
|
+
AND t.relname = ${resolvedTable}
|
|
514
|
+
AND c.conname = ${authIdentifier("email_length_check")}
|
|
515
|
+
`);
|
|
516
|
+
if (checkPresent.rows.length === 0) {
|
|
517
|
+
await db.execute(sql`
|
|
518
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
519
|
+
ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID
|
|
520
|
+
`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── Index: email verification token lookups ─────────────────────────
|
|
525
|
+
// `getUserByVerificationToken` filters on this column, which had no
|
|
526
|
+
// index — every click of a verification link was a sequential scan of
|
|
527
|
+
// the whole users table. Partial, because the column is NULL for every
|
|
528
|
+
// user who is not mid-verification, which is nearly all of them.
|
|
529
|
+
if (usersColumnTypes.has("email_verification_token")) {
|
|
530
|
+
await db.execute(sql`
|
|
531
|
+
CREATE INDEX IF NOT EXISTS ${sql.raw(`"${verificationTokenIndex}"`)}
|
|
532
|
+
ON ${sql.raw(usersTableName)} (email_verification_token)
|
|
533
|
+
WHERE email_verification_token IS NOT NULL
|
|
534
|
+
`);
|
|
535
|
+
}
|
|
536
|
+
|
|
382
537
|
// ── Migration: refresh_tokens become session-scoped, rotation-safe ──
|
|
383
538
|
// Two shapes are reconciled here, on EVERY table named refresh_tokens
|
|
384
539
|
// in whatever schema it lives (a database provisioned by an older era
|
package/src/auth/services.ts
CHANGED
|
@@ -54,6 +54,25 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
|
|
|
54
54
|
return key ? table[key] : undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The single definition of what an email address looks like in storage.
|
|
59
|
+
*
|
|
60
|
+
* Reads have always folded case; writes did not, and normalising was left to
|
|
61
|
+
* each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
|
|
62
|
+
* from a row no lookup can find — the account exists, every sign-in path
|
|
63
|
+
* reports no such user, and the byte-exact UNIQUE on the column does not stop a
|
|
64
|
+
* duplicate differing only in case. Applied on both sides here so the guarantee
|
|
65
|
+
* belongs to the repository rather than to its callers' discipline; the
|
|
66
|
+
* `lower(email)` unique index added in `ensureAuthTablesExist` is the database
|
|
67
|
+
* half of the same rule.
|
|
68
|
+
*
|
|
69
|
+
* Whitespace goes too: a trailing space survives the fold and reproduces the
|
|
70
|
+
* problem exactly.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeEmail<T>(email: T): T | string {
|
|
73
|
+
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/**
|
|
58
77
|
* PostgreSQL implementation of UserRepository.
|
|
59
78
|
* Handles all user-related database operations using Drizzle ORM.
|
|
@@ -184,7 +203,7 @@ export class UserService implements UserRepository {
|
|
|
184
203
|
const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
|
|
185
204
|
|
|
186
205
|
if ("id" in data) payload[idKey] = data.id;
|
|
187
|
-
if ("email" in data) payload[emailKey] = data.email;
|
|
206
|
+
if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
|
|
188
207
|
if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
|
|
189
208
|
if ("displayName" in data) payload[displayNameKey] = data.displayName;
|
|
190
209
|
if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
|
|
@@ -244,7 +263,7 @@ export class UserService implements UserRepository {
|
|
|
244
263
|
async getUserByEmail(email: string): Promise<UserData | null> {
|
|
245
264
|
const emailCol = getColumn(this.usersTable, "email");
|
|
246
265
|
if (!emailCol) return null;
|
|
247
|
-
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email
|
|
266
|
+
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
|
|
248
267
|
return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
|
|
249
268
|
}
|
|
250
269
|
|
package/src/cli-helpers.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { isManyToMany } from "@rebasepro/types";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import { execSync } from "child_process";
|
|
5
|
+
import { createRequire } from "module";
|
|
5
6
|
import { pathToFileURL } from "url";
|
|
6
7
|
import chalk from "chalk";
|
|
7
8
|
import { logger } from "@rebasepro/server";
|
|
@@ -10,6 +11,49 @@ import { moduleDir as __helpersDirname } from "./module-dir";
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Why is a dependency's binary missing — never installed, or installed with its
|
|
16
|
+
* build script blocked?
|
|
17
|
+
*
|
|
18
|
+
* These need opposite advice, and getting it wrong is not a cosmetic miss. pnpm
|
|
19
|
+
* 10+ refuses to run a dependency's lifecycle scripts unless it is allowlisted
|
|
20
|
+
* (`pnpm.onlyBuiltDependencies`, or `allowBuilds` in `pnpm-workspace.yaml`).
|
|
21
|
+
* `@ariga/atlas` downloads its platform binary in `preinstall`, so a blocked
|
|
22
|
+
* script leaves a state that looks like a successful install: the package is on
|
|
23
|
+
* disk with its `install.js` and `package.json`, `node_modules/.bin` is empty,
|
|
24
|
+
* the install exits 0, and the only signal is `ERR_PNPM_IGNORED_BUILDS` several
|
|
25
|
+
* screens up.
|
|
26
|
+
*
|
|
27
|
+
* Telling somebody in that state to install the package again sends them round
|
|
28
|
+
* the same loop forever — the add succeeds, the script is blocked again,
|
|
29
|
+
* nothing changes. Verified by doing it: `pnpm add @ariga/atlas` into a bare
|
|
30
|
+
* project yields exactly this, three "Failed to create bin … ENOENT" warnings
|
|
31
|
+
* and no binary.
|
|
32
|
+
*
|
|
33
|
+
* Resolution is attempted from the user's project first and this package
|
|
34
|
+
* second, matching the order {@link resolveLocalBin} searches — the driver may
|
|
35
|
+
* be installed a level up from where the command runs.
|
|
36
|
+
*/
|
|
37
|
+
export function diagnoseMissingBin(packageName: string): "not-installed" | "build-script-blocked" {
|
|
38
|
+
const bases = [
|
|
39
|
+
pathToFileURL(path.join(process.cwd(), "package.json")),
|
|
40
|
+
pathToFileURL(path.join(__helpersDirname, "package.json"))
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const base of bases) {
|
|
44
|
+
try {
|
|
45
|
+
// `package.json` rather than the package root: a package with an
|
|
46
|
+
// `exports` map that omits `.` is unresolvable by name even when it
|
|
47
|
+
// is perfectly installed, which would misreport it as absent.
|
|
48
|
+
createRequire(base).resolve(`${packageName}/package.json`);
|
|
49
|
+
return "build-script-blocked";
|
|
50
|
+
} catch {
|
|
51
|
+
// Try the next base.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return "not-installed";
|
|
55
|
+
}
|
|
56
|
+
|
|
13
57
|
export function resolveLocalBin(binName: string): string | null {
|
|
14
58
|
// Try to find node_modules/.bin upwards from __helpersDirname first (package-relative)
|
|
15
59
|
let dir = __helpersDirname;
|
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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,
|
|
@@ -635,9 +636,36 @@ async function runAtlas(
|
|
|
635
636
|
): Promise<string> {
|
|
636
637
|
const atlasBin = resolveLocalBin("atlas");
|
|
637
638
|
if (!atlasBin) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
639
|
+
// Two very different causes, and the advice for one is a loop for the
|
|
640
|
+
// other — see `diagnoseMissingBin`. This used to say "Install it with:
|
|
641
|
+
// pnpm add -D @ariga/atlas" unconditionally, which is the exact command
|
|
642
|
+
// that produces the far more common of the two states.
|
|
643
|
+
logger.error(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
|
|
644
|
+
|
|
645
|
+
if (diagnoseMissingBin("@ariga/atlas") === "build-script-blocked") {
|
|
646
|
+
logger.error(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
|
|
647
|
+
logger.error(chalk.gray(
|
|
648
|
+
" It downloads that binary in a `preinstall` script, and pnpm 10+ does not\n" +
|
|
649
|
+
" run a dependency's scripts unless you allow it. The install still exits 0,\n" +
|
|
650
|
+
" so the only sign is `Ignored build scripts: @ariga/atlas` in its output.\n"
|
|
651
|
+
));
|
|
652
|
+
logger.error(" Fix it with either:\n");
|
|
653
|
+
logger.error(chalk.bold(" pnpm approve-builds\n"));
|
|
654
|
+
logger.error(" or, to record it in the project (what `rebase init` scaffolds):\n");
|
|
655
|
+
logger.error(chalk.bold(
|
|
656
|
+
" // package.json\n" +
|
|
657
|
+
" \"pnpm\": { \"onlyBuiltDependencies\": [\"@ariga/atlas\"] }\n"
|
|
658
|
+
));
|
|
659
|
+
logger.error(chalk.gray(" Then re-run `pnpm install`.\n"));
|
|
660
|
+
} else {
|
|
661
|
+
logger.error(chalk.gray(" It is not installed in this project.\n"));
|
|
662
|
+
logger.error(" Install it with:\n");
|
|
663
|
+
logger.error(chalk.bold(" pnpm add -D @ariga/atlas\n"));
|
|
664
|
+
logger.error(chalk.gray(
|
|
665
|
+
" If pnpm then reports `Ignored build scripts`, also run `pnpm approve-builds` —\n" +
|
|
666
|
+
" the package carries a `preinstall` script that fetches the binary.\n"
|
|
667
|
+
));
|
|
668
|
+
}
|
|
641
669
|
process.exit(1);
|
|
642
670
|
}
|
|
643
671
|
|
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,
|
|
@@ -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()
|