@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.1-canary.g009ed95
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 +33 -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/collections/buildRegistry.d.ts +1 -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-BrUVgjz3.js +57 -0
- package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
- package/dist/history/HistoryService.d.ts +9 -29
- package/dist/index.es.js +1234 -9753
- 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/dynamic-tables.d.ts +1 -1
- 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/schema/introspect-runtime.d.ts +1 -1
- package/dist/services/FetchService.d.ts +40 -2
- 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 +24 -1
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +4 -2
- package/dist/{src-3VmUJ8Xn.js → src-CzbghKwf.js} +464 -187
- package/dist/src-CzbghKwf.js.map +1 -0
- package/dist/{src-D5xBTl32.js → src-DoU9yPqq.js} +79 -189
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +162 -7
- package/dist/utils/pg-error-utils.d.ts +25 -3
- 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 +212 -36
- 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/buildRegistry.ts +1 -1
- 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/history/HistoryService.ts +13 -31
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/dynamic-tables.ts +1 -1
- 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 +33 -8
- 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 +32 -10
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/FetchService.ts +148 -18
- 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 +40 -1
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +4 -2
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +539 -50
- package/src/utils/pg-error-utils.ts +98 -3
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-DGMYK0fr.js +0 -304
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +0 -1
- package/dist/src-3VmUJ8Xn.js.map +0 -1
- package/dist/src-D5xBTl32.js.map +0 -1
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DatabaseAdmin,
|
|
16
16
|
type DataDriver,
|
|
17
17
|
CollectionConfig,
|
|
18
|
+
isRelationalCollectionConfig,
|
|
18
19
|
type HistoryConfig,
|
|
19
20
|
InitializedDriver,
|
|
20
21
|
RealtimeProvider,
|
|
@@ -41,6 +42,7 @@ import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cd
|
|
|
41
42
|
import { collectJunctionLinks } from "./services/cdc/junction-tables";
|
|
42
43
|
import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
|
|
43
44
|
import { isChannelBusInstance } from "@rebasepro/types";
|
|
45
|
+
import { configureUnknownFilterFields, type UnknownFilterFieldsMode } from "./utils/drizzle-conditions";
|
|
44
46
|
|
|
45
47
|
export interface PostgresDriverConfig {
|
|
46
48
|
connectionString?: string;
|
|
@@ -67,6 +69,13 @@ export interface PostgresDriverConfig {
|
|
|
67
69
|
* instance and wrong for two. See {@link ChannelBusConfig}.
|
|
68
70
|
*/
|
|
69
71
|
realtime?: RealtimeChannelsConfig;
|
|
72
|
+
/**
|
|
73
|
+
* What to do with a filter field that resolves to no column at all.
|
|
74
|
+
* Defaults to `"error"` — a filter that cannot be compiled would otherwise
|
|
75
|
+
* be dropped, and a dropped condition can only widen the result set.
|
|
76
|
+
* Set to `"warn"` to restore the pre-fix behaviour of dropping it silently.
|
|
77
|
+
*/
|
|
78
|
+
unknownFilterFields?: UnknownFilterFieldsMode;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
@@ -93,6 +102,40 @@ export interface PostgresDriverInternals {
|
|
|
93
102
|
|
|
94
103
|
// Re-export from shared CLI error utilities
|
|
95
104
|
import { isEconnrefused } from "./cli-errors";
|
|
105
|
+
import { classifyConnectFailure } from "./utils/pg-error-utils";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Which table name the boot-time drift check should look for, for one collection.
|
|
109
|
+
*
|
|
110
|
+
* A declared `table` IS the table name, not a hint to be second-guessed. This
|
|
111
|
+
* used to ask the registry whether it had indexed the declared name and fall
|
|
112
|
+
* back to the SLUG when it had not — but "the registry does not know this table"
|
|
113
|
+
* is exactly the condition the drift check exists to report, so the fallback
|
|
114
|
+
* fired precisely when it was most harmful.
|
|
115
|
+
*
|
|
116
|
+
* A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
|
|
117
|
+
* missing table `usage-daily`: a name that does not exist, should never exist,
|
|
118
|
+
* and that nobody can find by looking. Worse, the remediation the caller prints
|
|
119
|
+
* says to run `rebase db push` — which would then CREATE that invented table
|
|
120
|
+
* beside the correct one, the same "second copy" hazard the misplaced-schema
|
|
121
|
+
* branch further down exists to prevent. Seen in production, where a correctly
|
|
122
|
+
* migrated database reported drift on every boot.
|
|
123
|
+
*
|
|
124
|
+
* The slug is used only when nothing was declared, which is the config shape
|
|
125
|
+
* where the slug genuinely is the table name.
|
|
126
|
+
*
|
|
127
|
+
* Exported for its own test: the caller needs a live pool and a real database,
|
|
128
|
+
* and this is the part that was wrong.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveDriftCheckName(
|
|
131
|
+
col: CollectionConfig,
|
|
132
|
+
registeredTableNames: string[]
|
|
133
|
+
): string {
|
|
134
|
+
const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
|
|
135
|
+
return declaredTable
|
|
136
|
+
?? registeredTableNames.find((k) => k === col.slug)
|
|
137
|
+
?? col.slug;
|
|
138
|
+
}
|
|
96
139
|
|
|
97
140
|
/**
|
|
98
141
|
* Default PostgreSQL bootstrapper.
|
|
@@ -106,16 +149,23 @@ import { isEconnrefused } from "./cli-errors";
|
|
|
106
149
|
* ```
|
|
107
150
|
*/
|
|
108
151
|
export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
|
|
152
|
+
// Applied at construction rather than threaded through every read: the
|
|
153
|
+
// condition builder's static methods are reached from call sites that
|
|
154
|
+
// carry no config. See `UnknownFilterFieldsMode`.
|
|
155
|
+
if (pgConfig.unknownFilterFields) {
|
|
156
|
+
configureUnknownFilterFields(pgConfig.unknownFilterFields);
|
|
157
|
+
}
|
|
158
|
+
|
|
109
159
|
return {
|
|
110
160
|
type: "postgres",
|
|
111
161
|
|
|
112
162
|
async initializeDriver(config: unknown): Promise<InitializedDriver> {
|
|
113
163
|
// config is passed from coordinator, we merge it with our internal pgConfig if needed
|
|
114
164
|
// Currently config from init.ts is `{ collections, collectionRegistry, mode }`
|
|
115
|
-
const { collections, collectionRegistry,
|
|
165
|
+
const { collections, collectionRegistry, introspectCollections, baas } = config as {
|
|
116
166
|
collections?: CollectionConfig[];
|
|
117
167
|
collectionRegistry?: unknown;
|
|
118
|
-
|
|
168
|
+
introspectCollections?: boolean;
|
|
119
169
|
baas?: { unprotectedTables?: "exclude" | "serve" };
|
|
120
170
|
};
|
|
121
171
|
// Secure by default: a table with no RLS is not served.
|
|
@@ -126,13 +176,13 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
126
176
|
? (connection as Record<string, unknown>).$client
|
|
127
177
|
: connection) as import("pg").Pool;
|
|
128
178
|
|
|
129
|
-
// ──
|
|
179
|
+
// ── No declared collections: derive the schema from the database ──
|
|
130
180
|
// No collection files and no generated drizzle schema exist, so read
|
|
131
181
|
// the live database and build both from what is actually there.
|
|
132
182
|
let introspectedCollections: CollectionConfig[] | undefined;
|
|
133
183
|
let introspectedTables: Record<string, PgTable> | undefined;
|
|
134
184
|
let introspectedRelations: Record<string, Relations> | undefined;
|
|
135
|
-
if (
|
|
185
|
+
if (introspectCollections && (!collections || collections.length === 0)) {
|
|
136
186
|
const pgSchemaName = pgConfig.introspectionSchema ?? "public";
|
|
137
187
|
const schema = await introspectSchema(rawClient, pgSchemaName);
|
|
138
188
|
|
|
@@ -244,9 +294,45 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
244
294
|
throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
|
|
245
295
|
}
|
|
246
296
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
297
|
+
/*
|
|
298
|
+
* Everything else.
|
|
299
|
+
*
|
|
300
|
+
* Two problems with what used to happen here. First, the logged
|
|
301
|
+
* error was Drizzle's wrapper — `Failed query: SELECT 1` and a
|
|
302
|
+
* stack through drizzle internals — while the sentence that
|
|
303
|
+
* actually says what is wrong ("password authentication failed
|
|
304
|
+
* for user …", "database … does not exist") sits in `.cause`
|
|
305
|
+
* and was never printed. A developer with a typo in their
|
|
306
|
+
* DATABASE_URL got two walls of stack trace and no cause.
|
|
307
|
+
*
|
|
308
|
+
* Second, "continuing… the pool may recover" is only true for
|
|
309
|
+
* a transient fault. A wrong password or a missing database is
|
|
310
|
+
* settled: the next query fails the same way, so the process
|
|
311
|
+
* died seconds later anyway — after printing a reassurance.
|
|
312
|
+
* Those now fail here, where the message can be about the
|
|
313
|
+
* cause rather than about whichever query ran next.
|
|
314
|
+
*/
|
|
315
|
+
const { fatal, reason, code } = classifyConnectFailure(err);
|
|
316
|
+
const detail = code ? ` [${code}]` : "";
|
|
317
|
+
|
|
318
|
+
if (fatal) {
|
|
319
|
+
logger.error(
|
|
320
|
+
`\n` +
|
|
321
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
322
|
+
` ❌ PostgreSQL refused the connection\n` +
|
|
323
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
324
|
+
`\n` +
|
|
325
|
+
` ${reason}${detail}\n` +
|
|
326
|
+
`\n` +
|
|
327
|
+
` The server is reachable, so this is the credentials or the\n` +
|
|
328
|
+
` database name in DATABASE_URL — check them in your .env.\n` +
|
|
329
|
+
`\n` +
|
|
330
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
|
331
|
+
);
|
|
332
|
+
throw new Error(`PostgreSQL refused the connection: ${reason}${detail}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
logger.error(`❌ Failed to connect to PostgreSQL: ${reason}${detail}`, { error: err });
|
|
250
336
|
logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
|
|
251
337
|
}
|
|
252
338
|
|
|
@@ -471,22 +557,28 @@ table: link.table });
|
|
|
471
557
|
try {
|
|
472
558
|
const registeredCollections = registry.getCollections();
|
|
473
559
|
if (registeredCollections.length > 0) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
560
|
+
// Deliberately unfiltered by schema: a table that is missing
|
|
561
|
+
// from where the collection says it lives is very often
|
|
562
|
+
// sitting in another schema entirely (see the misplaced-table
|
|
563
|
+
// report below), and knowing *which* is the whole answer.
|
|
478
564
|
const result = await schemaAwareDb.execute(sql.raw(`
|
|
479
565
|
SELECT table_name, table_schema
|
|
480
566
|
FROM information_schema.tables
|
|
481
|
-
WHERE table_schema IN (
|
|
567
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
482
568
|
AND table_type = 'BASE TABLE'
|
|
483
569
|
`));
|
|
570
|
+
const tablesByName = new Map<string, string[]>();
|
|
571
|
+
for (const row of result.rows as Array<{ table_name: string; table_schema: string }>) {
|
|
572
|
+
const schemas = tablesByName.get(row.table_name) ?? [];
|
|
573
|
+
schemas.push(row.table_schema);
|
|
574
|
+
tablesByName.set(row.table_name, schemas);
|
|
575
|
+
}
|
|
484
576
|
const dbTables = new Set(
|
|
485
577
|
(result.rows as Array<{ table_name: string; table_schema: string }>).map(r =>
|
|
486
578
|
r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
|
|
487
579
|
)
|
|
488
580
|
);
|
|
489
|
-
const missing: Array<{ slug: string; table: string }> = [];
|
|
581
|
+
const missing: Array<{ slug: string; table: string; foundIn: string[] }> = [];
|
|
490
582
|
for (const col of registeredCollections) {
|
|
491
583
|
// Auth owns its table and creates it later in this same
|
|
492
584
|
// boot (initializeAuth → ensureAuthTablesExist), so it is
|
|
@@ -497,40 +589,72 @@ table: link.table });
|
|
|
497
589
|
if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
|
|
498
590
|
|
|
499
591
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
500
|
-
const
|
|
501
|
-
col.table ?? col.slug
|
|
502
|
-
)
|
|
503
|
-
? (col.table ?? col.slug)
|
|
504
|
-
: col.slug;
|
|
505
|
-
// Resolve the actual table name the registry stored
|
|
506
|
-
const resolvedTable = registry.getTableNames().find((k) =>
|
|
507
|
-
k === tableName ||
|
|
508
|
-
k === col.slug
|
|
509
|
-
);
|
|
510
|
-
const checkName = resolvedTable ?? tableName;
|
|
592
|
+
const checkName = resolveDriftCheckName(col, registry.getTableNames());
|
|
511
593
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
512
594
|
if (!dbTables.has(fullCheckName)) {
|
|
513
595
|
// Report what was actually looked up: an unqualified
|
|
514
596
|
// "users" sends people hunting for public.users.
|
|
515
597
|
missing.push({ slug: col.slug,
|
|
516
|
-
table: fullCheckName
|
|
598
|
+
table: fullCheckName,
|
|
599
|
+
foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
|
|
517
600
|
}
|
|
518
601
|
}
|
|
519
602
|
if (missing.length > 0) {
|
|
520
603
|
const lines = missing.map(
|
|
521
|
-
m => ` • collection "${m.slug}" → table "${m.table}"`
|
|
604
|
+
m => ` • collection "${m.slug}" → table "${m.table}"` +
|
|
605
|
+
(m.foundIn.length > 0
|
|
606
|
+
? ` — but a table of that name exists in ${m.foundIn.map(s => `"${s}"`).join(", ")}`
|
|
607
|
+
: "")
|
|
522
608
|
);
|
|
609
|
+
// A table that exists under the same name in another
|
|
610
|
+
// schema is not ordinary drift: it is almost always this
|
|
611
|
+
// framework's own `search_path` hazard. Postgres resolves
|
|
612
|
+
// unqualified SQL through `"$user", public`, and this
|
|
613
|
+
// project creates a schema named `rebase` while every
|
|
614
|
+
// template names the database role `rebase` too — so an
|
|
615
|
+
// unqualified CREATE TABLE (a hand-written migration, the
|
|
616
|
+
// SQL editor, drizzle-kit) lands in `rebase`, and the
|
|
617
|
+
// runtime, which now pins `search_path=public`, cannot see
|
|
618
|
+
// it. Say so, because "missing table" sends people to
|
|
619
|
+
// re-run a push that will create a *second* copy.
|
|
620
|
+
const misplaced = missing.filter(m => m.foundIn.length > 0);
|
|
621
|
+
const misplacedHelp = misplaced.length === 0 ? [] : [
|
|
622
|
+
" Those tables exist — in the wrong schema. Unqualified SQL run by a",
|
|
623
|
+
" role whose name matches a schema (`rebase` is both, by default)",
|
|
624
|
+
" resolves through search_path's \"$user\" and creates there. Move them:",
|
|
625
|
+
...misplaced.map(m =>
|
|
626
|
+
` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`
|
|
627
|
+
),
|
|
628
|
+
" and qualify the SQL that created them, or pin search_path in DATABASE_URL",
|
|
629
|
+
" (`?options=-c%20search_path%3Dpublic`).",
|
|
630
|
+
""
|
|
631
|
+
];
|
|
632
|
+
// This runtime creates collection tables (and their RLS)
|
|
633
|
+
// at boot unless REBASE_MIGRATE_ON_BOOT=none, so a drift
|
|
634
|
+
// this late means that step was disabled, could not run,
|
|
635
|
+
// or failed — the guidance names a path for both a managed
|
|
636
|
+
// tenant (whose in-cluster database `pnpm db:push` cannot
|
|
637
|
+
// reach) and a self-host. Naming only the pnpm scripts, as
|
|
638
|
+
// this once did, told a managed operator to run a command
|
|
639
|
+
// that structurally cannot touch their database.
|
|
523
640
|
logger.warn([
|
|
524
641
|
"",
|
|
525
|
-
"
|
|
526
|
-
|
|
527
|
-
"
|
|
528
|
-
...
|
|
529
|
-
"
|
|
530
|
-
"
|
|
531
|
-
"
|
|
532
|
-
"
|
|
533
|
-
"
|
|
642
|
+
"⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
|
|
643
|
+
...lines,
|
|
644
|
+
"",
|
|
645
|
+
...misplacedHelp,
|
|
646
|
+
" This runtime applies the collection schema at boot unless",
|
|
647
|
+
" REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
|
|
648
|
+
" log lines above — this drift means that step was off, skipped, or failed.",
|
|
649
|
+
" • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
|
|
650
|
+
" \"ensure\"; the runtime applies the schema to the tenant DB.",
|
|
651
|
+
" If the log above says the driver does not implement collection-table",
|
|
652
|
+
" creation, THIS driver is too old to do it. A driver is installed from",
|
|
653
|
+
" your bundle's dependencies, not supplied by the platform image, so a",
|
|
654
|
+
" newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
|
|
655
|
+
" in your project's package.json and redeploy.",
|
|
656
|
+
" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
|
|
657
|
+
" against DATABASE_URL.",
|
|
534
658
|
""
|
|
535
659
|
].join("\n"));
|
|
536
660
|
}
|
|
@@ -694,7 +818,59 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
|
|
|
694
818
|
collections as Parameters<typeof ensureCollectionTables>[1],
|
|
695
819
|
log
|
|
696
820
|
);
|
|
697
|
-
|
|
821
|
+
for (const failure of plan.failures) {
|
|
822
|
+
logger.warn(
|
|
823
|
+
`🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the ` +
|
|
824
|
+
`collection still serves, but rows are not policed by this constraint: ${failure.error}`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return { applied: plan.actions.length - plan.failures.length };
|
|
828
|
+
},
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Apply the collections' RLS policies — ENABLE ROW LEVEL SECURITY and the
|
|
832
|
+
* `securityRules` compiled to `CREATE POLICY` — so a freshly provisioned
|
|
833
|
+
* database serves data instead of denying every user-context read.
|
|
834
|
+
*
|
|
835
|
+
* The companion to {@link ensureCollectionSchema}: that creates the
|
|
836
|
+
* tables, this makes them servable. Boot runs it *after* auth
|
|
837
|
+
* initialization, because the generated policies call `auth.uid()` /
|
|
838
|
+
* `auth.roles()`, and `CREATE POLICY` validates those functions exist.
|
|
839
|
+
*
|
|
840
|
+
* Runs through the same drizzle handle, one statement at a time (that
|
|
841
|
+
* handle speaks the extended query protocol, which rejects multi-command
|
|
842
|
+
* strings). Failures are per-table and non-fatal: a table left un-policed
|
|
843
|
+
* stays RLS-enabled, so it denies rather than leaks.
|
|
844
|
+
*/
|
|
845
|
+
async ensureCollectionPolicies(
|
|
846
|
+
collections: unknown[],
|
|
847
|
+
driverResult: InitializedDriver,
|
|
848
|
+
log?: (message: string) => void
|
|
849
|
+
): Promise<{ applied: number }> {
|
|
850
|
+
const internals = driverResult.internals as PostgresDriverInternals;
|
|
851
|
+
const { ensureCollectionPolicies } = await import("./schema/ensure-collection-policies");
|
|
852
|
+
const queryable = {
|
|
853
|
+
async query<T>(text: string): Promise<{ rows: T[] }> {
|
|
854
|
+
const result = await internals.db.execute(sql.raw(text));
|
|
855
|
+
const rows = (result as unknown as { rows?: T[] }).rows;
|
|
856
|
+
return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
const outcome = await ensureCollectionPolicies(
|
|
860
|
+
queryable,
|
|
861
|
+
collections as CollectionConfig[],
|
|
862
|
+
log
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
for (const skip of outcome.skipped) {
|
|
866
|
+
logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
867
|
+
}
|
|
868
|
+
for (const failure of outcome.failures) {
|
|
869
|
+
logger.warn(
|
|
870
|
+
`🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return { applied: outcome.policiesApplied };
|
|
698
874
|
},
|
|
699
875
|
|
|
700
876
|
getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
|
|
@@ -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,28 @@ 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
|
+
* Re-exported rather than defined here: `@rebasepro/server` and
|
|
73
|
+
* `@rebasepro/server-mongo` write this column too, and a second copy of this
|
|
74
|
+
* rule is the defect it exists to prevent.
|
|
75
|
+
*/
|
|
76
|
+
import { normalizeEmail } from "@rebasepro/common";
|
|
77
|
+
export { normalizeEmail };
|
|
78
|
+
|
|
57
79
|
/**
|
|
58
80
|
* PostgreSQL implementation of UserRepository.
|
|
59
81
|
* Handles all user-related database operations using Drizzle ORM.
|
|
@@ -184,7 +206,7 @@ export class UserService implements UserRepository {
|
|
|
184
206
|
const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
|
|
185
207
|
|
|
186
208
|
if ("id" in data) payload[idKey] = data.id;
|
|
187
|
-
if ("email" in data) payload[emailKey] = data.email;
|
|
209
|
+
if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
|
|
188
210
|
if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
|
|
189
211
|
if ("displayName" in data) payload[displayNameKey] = data.displayName;
|
|
190
212
|
if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
|
|
@@ -244,7 +266,7 @@ export class UserService implements UserRepository {
|
|
|
244
266
|
async getUserByEmail(email: string): Promise<UserData | null> {
|
|
245
267
|
const emailCol = getColumn(this.usersTable, "email");
|
|
246
268
|
if (!emailCol) return null;
|
|
247
|
-
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email
|
|
269
|
+
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
|
|
248
270
|
return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
|
|
249
271
|
}
|
|
250
272
|
|