@rebasepro/server-postgres 0.10.1-canary.7609565 → 0.10.1-canary.7801eed
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/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +1093 -4941
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +55 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
-
import { FilterValues, WhereFilterOp,
|
|
3
|
+
import { FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation } from "@rebasepro/types";
|
|
4
4
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
5
5
|
/** Drizzle dynamic query builder — accepts innerJoin + where chaining */
|
|
6
6
|
export interface DrizzleDynamicQuery {
|
|
@@ -18,6 +18,47 @@ export interface DrizzleDynamicQuery {
|
|
|
18
18
|
* const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
|
|
19
19
|
*/
|
|
20
20
|
export declare class DrizzleConditionBuilder {
|
|
21
|
+
/**
|
|
22
|
+
* Express "reachable from this parent through this relation" as a plain
|
|
23
|
+
* `WHERE` condition on the target table.
|
|
24
|
+
*
|
|
25
|
+
* This is the primitive that lets a relation be a *filter* rather than an
|
|
26
|
+
* addressing scheme. A nested listing used to be served by its own query
|
|
27
|
+
* builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
|
|
28
|
+
* did not have and lost the options the root pipeline did have (offset,
|
|
29
|
+
* filter, orderBy, include). Reduced to a condition, the same listing runs
|
|
30
|
+
* through the ordinary collection query, so it inherits all of them and
|
|
31
|
+
* there is one read path instead of two.
|
|
32
|
+
*
|
|
33
|
+
* The shapes:
|
|
34
|
+
* - inverse FK → `target.<fk> = :parentId`, a column comparison.
|
|
35
|
+
* - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
|
|
36
|
+
* target's key, so the junction never multiplies rows the
|
|
37
|
+
* way an `INNER JOIN` would.
|
|
38
|
+
* - `joinPath` → the same `EXISTS`, with the path's steps joined inside
|
|
39
|
+
* it and the final step correlating to the outer row.
|
|
40
|
+
*/
|
|
41
|
+
static buildRelationScopeCondition(relation: ResolvedRelation,
|
|
42
|
+
/**
|
|
43
|
+
* Lazy: only `via` and `belongsTo` need the parent's own table. A
|
|
44
|
+
* foreign key on the target and a junction are both expressible from
|
|
45
|
+
* the parent's *id* alone, and requiring the table for them would make
|
|
46
|
+
* a child listing fail on a parent whose table isn't registered.
|
|
47
|
+
*/
|
|
48
|
+
parent: () => {
|
|
49
|
+
table: PgTable<any>;
|
|
50
|
+
idColumn: AnyPgColumn;
|
|
51
|
+
}, parentId: string | number, targetTable: PgTable<any>, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): SQL;
|
|
52
|
+
/**
|
|
53
|
+
* `EXISTS` for an explicit `joinPath`.
|
|
54
|
+
*
|
|
55
|
+
* The path is declared source → target. The subquery replays every step but
|
|
56
|
+
* the last from inside, and turns the last one into the correlation with the
|
|
57
|
+
* outer target row — so the target table is never named twice and needs no
|
|
58
|
+
* alias. Each intermediate table is aliased positionally, which keeps a path
|
|
59
|
+
* that revisits a table (a self-referencing many-to-many) unambiguous.
|
|
60
|
+
*/
|
|
61
|
+
private static buildJoinPathScopeCondition;
|
|
21
62
|
/**
|
|
22
63
|
* Build filter conditions from FilterValues
|
|
23
64
|
*/
|
|
@@ -33,7 +74,21 @@ export declare class DrizzleConditionBuilder {
|
|
|
33
74
|
/**
|
|
34
75
|
* Build relation-based conditions for different relation types
|
|
35
76
|
*/
|
|
36
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Joins and where-conditions that reach a relation's target rows.
|
|
79
|
+
*
|
|
80
|
+
* One case per kind. This used to be a chain of six `else if`s over
|
|
81
|
+
* `cardinality`/`direction`/`through`, ending in
|
|
82
|
+
* `findCorrespondingJunctionTable` — a search through the *target's* own
|
|
83
|
+
* relations to work out whether an "inverse many" was a one-to-many or the
|
|
84
|
+
* far side of a junction. That search is gone: the kind says which it is.
|
|
85
|
+
*
|
|
86
|
+
* The owning/inverse split for junctions is gone too. Both variants built
|
|
87
|
+
* the identical condition — `through` is always written from the declaring
|
|
88
|
+
* side's point of view — so the second was a distinction without a
|
|
89
|
+
* difference and one of the places the two could drift apart.
|
|
90
|
+
*/
|
|
91
|
+
static buildRelationConditions(relation: ResolvedRelation, parentId: string | number | (string | number)[], targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): {
|
|
37
92
|
joinConditions: {
|
|
38
93
|
table: PgTable<any>;
|
|
39
94
|
condition: SQL;
|
|
@@ -57,11 +112,13 @@ export declare class DrizzleConditionBuilder {
|
|
|
57
112
|
*/
|
|
58
113
|
private static buildJunctionTableConditions;
|
|
59
114
|
/**
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
115
|
+
* The condition for a relation whose link is a single column.
|
|
116
|
+
*
|
|
117
|
+
* Two cases. It had five: two of them existed only to throw ("should not be
|
|
118
|
+
* called directly", "lacks proper configuration"), and one guessed a column
|
|
119
|
+
* name by appending `_id` to `inverseRelationName` when no foreign key had
|
|
120
|
+
* been resolved. All three were reachable only because the old type let a
|
|
121
|
+
* relation arrive here under-specified. It cannot now.
|
|
65
122
|
*/
|
|
66
123
|
private static buildSimpleRelationCondition;
|
|
67
124
|
/**
|
|
@@ -83,11 +140,15 @@ export declare class DrizzleConditionBuilder {
|
|
|
83
140
|
/**
|
|
84
141
|
* Build relation-based query with joins and conditions
|
|
85
142
|
*/
|
|
86
|
-
static buildRelationQuery<T extends DrizzleDynamicQuery>(baseQuery: T, relation:
|
|
143
|
+
static buildRelationQuery<T extends DrizzleDynamicQuery>(baseQuery: T, relation: ResolvedRelation, parentId: string | number | (string | number)[], targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T;
|
|
87
144
|
/**
|
|
88
|
-
*
|
|
145
|
+
* A count over a relation's target rows.
|
|
146
|
+
*
|
|
147
|
+
* The junction case counts `distinct` because the caller's query joins the
|
|
148
|
+
* junction; the owning/inverse pair that used to sit here built the same
|
|
149
|
+
* query twice.
|
|
89
150
|
*/
|
|
90
|
-
static buildRelationCountQuery<T extends DrizzleDynamicQuery>(baseCountQuery: T, relation:
|
|
151
|
+
static buildRelationCountQuery<T extends DrizzleDynamicQuery>(baseCountQuery: T, relation: ResolvedRelation, parentId: string | number, targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T;
|
|
91
152
|
/**
|
|
92
153
|
* Build join path conditions for count queries
|
|
93
154
|
*/
|
|
@@ -96,10 +157,6 @@ export declare class DrizzleConditionBuilder {
|
|
|
96
157
|
* Build junction table conditions for count queries
|
|
97
158
|
*/
|
|
98
159
|
private static buildJunctionCountQuery;
|
|
99
|
-
/**
|
|
100
|
-
* Build inverse junction table conditions for count queries
|
|
101
|
-
*/
|
|
102
|
-
private static buildInverseJunctionCountQuery;
|
|
103
160
|
/**
|
|
104
161
|
* Helper method to extract table names from columns
|
|
105
162
|
*/
|
|
@@ -108,10 +165,6 @@ export declare class DrizzleConditionBuilder {
|
|
|
108
165
|
* Helper method to extract column names from columns
|
|
109
166
|
*/
|
|
110
167
|
static getColumnNamesFromColumns(columns: string | string[]): string[];
|
|
111
|
-
/**
|
|
112
|
-
* Find the corresponding junction table for an inverse many-to-many relation
|
|
113
|
-
*/
|
|
114
|
-
private static findCorrespondingJunctionTable;
|
|
115
168
|
/**
|
|
116
169
|
* Build vector similarity search expressions for pgvector.
|
|
117
170
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.10.1-canary.
|
|
4
|
+
"version": "0.10.1-canary.7801eed",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -47,21 +47,20 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"pg": "^8.21.0",
|
|
49
49
|
"ws": "^8.21.0",
|
|
50
|
-
"@rebasepro/codegen": "0.10.1-canary.
|
|
51
|
-
"@rebasepro/common": "0.10.1-canary.
|
|
52
|
-
"@rebasepro/server": "0.10.1-canary.
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
50
|
+
"@rebasepro/codegen": "0.10.1-canary.7801eed",
|
|
51
|
+
"@rebasepro/common": "0.10.1-canary.7801eed",
|
|
52
|
+
"@rebasepro/server": "0.10.1-canary.7801eed",
|
|
53
|
+
"@rebasepro/types": "0.10.1-canary.7801eed",
|
|
54
|
+
"@rebasepro/utils": "0.10.1-canary.7801eed"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@hono/node-server": "^2.0.
|
|
57
|
+
"@hono/node-server": "^2.0.11",
|
|
58
58
|
"@jest/globals": "^30.4.1",
|
|
59
59
|
"@types/jest": "^30.0.0",
|
|
60
60
|
"@types/node": "^25.9.3",
|
|
61
61
|
"@types/pg": "^8.20.0",
|
|
62
62
|
"@types/ws": "^8.18.1",
|
|
63
|
-
"
|
|
64
|
-
"hono": "^4.12.25",
|
|
63
|
+
"hono": "^4.12.27",
|
|
65
64
|
"jest": "^30.4.2",
|
|
66
65
|
"ts-jest": "^29.4.11",
|
|
67
66
|
"typescript": "^6.0.3",
|
|
@@ -28,6 +28,7 @@ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegi
|
|
|
28
28
|
import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server";
|
|
29
29
|
import { getTableName as getCollectionTableName } from "@rebasepro/common";
|
|
30
30
|
import { ensureAuthTablesExist } from "./auth/ensure-tables";
|
|
31
|
+
import { probeAuthSchema, resolveAuthSchema } from "./auth/schema-version";
|
|
31
32
|
import { AuthSchemaTables, PostgresAuthRepository, UserService } from "./auth/services";
|
|
32
33
|
import { createAuthSchema } from "./schema/auth-schema";
|
|
33
34
|
import { HistoryService } from "./history/HistoryService";
|
|
@@ -37,6 +38,7 @@ import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./s
|
|
|
37
38
|
import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
|
|
38
39
|
import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
|
|
39
40
|
import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
|
|
41
|
+
import { collectJunctionLinks } from "./services/cdc/junction-tables";
|
|
40
42
|
import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
|
|
41
43
|
import { isChannelBusInstance } from "@rebasepro/types";
|
|
42
44
|
|
|
@@ -233,8 +235,8 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
233
235
|
` The database server is not running or is not accepting\n` +
|
|
234
236
|
` connections. Common fixes:\n` +
|
|
235
237
|
`\n` +
|
|
238
|
+
` • docker compose up -d db (the service a Rebase scaffold ships)\n` +
|
|
236
239
|
` • brew services start postgresql@18\n` +
|
|
237
|
-
` • docker compose up -d postgres\n` +
|
|
238
240
|
` • Verify DATABASE_URL in your .env file\n` +
|
|
239
241
|
`\n` +
|
|
240
242
|
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
|
@@ -417,6 +419,14 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
417
419
|
table: getCollectionTableName(c)
|
|
418
420
|
}))
|
|
419
421
|
.filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
|
|
422
|
+
// Junction tables back no collection, so the list above misses
|
|
423
|
+
// them — and a link or unlink is a write nobody would hear
|
|
424
|
+
// about. Their rows are the contents of a child list, which is
|
|
425
|
+
// as much a change as a write to the rows themselves.
|
|
426
|
+
for (const link of collectJunctionLinks(registry)) {
|
|
427
|
+
cdcTables.push({ schema: link.schema,
|
|
428
|
+
table: link.table });
|
|
429
|
+
}
|
|
420
430
|
// Provisioning throws only when the connection can't create the
|
|
421
431
|
// trigger function (insufficient privilege); enableCdc throws when
|
|
422
432
|
// the LISTEN connection can't be established. Either → fall back.
|
|
@@ -622,7 +632,10 @@ table: fullCheckName });
|
|
|
622
632
|
return { userService,
|
|
623
633
|
roleService: userService,
|
|
624
634
|
emailService,
|
|
625
|
-
authRepository
|
|
635
|
+
authRepository,
|
|
636
|
+
// Bound to the same schema `ensureAuthTablesExist` just migrated, so the
|
|
637
|
+
// health endpoint reports on the tables auth actually reads.
|
|
638
|
+
schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection)) };
|
|
626
639
|
},
|
|
627
640
|
|
|
628
641
|
async initializeHistory(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: HistoryService } | undefined> {
|
|
@@ -644,6 +657,46 @@ authRepository };
|
|
|
644
657
|
return internals.realtimeService;
|
|
645
658
|
},
|
|
646
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Create any collection tables, columns and enum types the database is
|
|
662
|
+
* missing — additively, never destructively.
|
|
663
|
+
*
|
|
664
|
+
* This is what lets the managed runtime boot a project against a fresh
|
|
665
|
+
* database and actually serve it. Before this, only auth tables were
|
|
666
|
+
* ensured, so a managed tenant came up with working sign-in and a 500 on
|
|
667
|
+
* every data route.
|
|
668
|
+
*
|
|
669
|
+
* Runs through the drizzle handle's underlying session so it uses the
|
|
670
|
+
* same connection (and therefore the same privileges) the driver already
|
|
671
|
+
* proved it can bootstrap with.
|
|
672
|
+
*/
|
|
673
|
+
async ensureCollectionSchema(
|
|
674
|
+
collections: unknown[],
|
|
675
|
+
driverResult: InitializedDriver,
|
|
676
|
+
log?: (message: string) => void
|
|
677
|
+
): Promise<{ applied: number }> {
|
|
678
|
+
const internals = driverResult.internals as PostgresDriverInternals;
|
|
679
|
+
const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
|
|
680
|
+
// Runs through the drizzle handle the driver already bootstrapped
|
|
681
|
+
// with, so it uses exactly the connection and privileges that were
|
|
682
|
+
// proven to work. Every statement is DDL or a catalogue read with no
|
|
683
|
+
// bindable values (schema names are identifiers), and the module
|
|
684
|
+
// validates them before they reach a string.
|
|
685
|
+
const queryable = {
|
|
686
|
+
async query<T>(text: string): Promise<{ rows: T[] }> {
|
|
687
|
+
const result = await internals.db.execute(sql.raw(text));
|
|
688
|
+
const rows = (result as unknown as { rows?: T[] }).rows;
|
|
689
|
+
return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const plan = await ensureCollectionTables(
|
|
693
|
+
queryable,
|
|
694
|
+
collections as Parameters<typeof ensureCollectionTables>[1],
|
|
695
|
+
log
|
|
696
|
+
);
|
|
697
|
+
return { applied: plan.actions.length };
|
|
698
|
+
},
|
|
699
|
+
|
|
647
700
|
getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
|
|
648
701
|
const internals = driverResult.internals as PostgresDriverInternals;
|
|
649
702
|
return internals.driver.admin;
|
|
@@ -2,6 +2,12 @@ import { sql } from "drizzle-orm";
|
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { logger } from "@rebasepro/server";
|
|
4
4
|
import type { CollectionConfig } from "@rebasepro/types";
|
|
5
|
+
import {
|
|
6
|
+
AuthSchemaVersionError,
|
|
7
|
+
assertAuthSchemaCompatible,
|
|
8
|
+
resolveAuthSchema,
|
|
9
|
+
stampAuthSchemaVersion
|
|
10
|
+
} from "./schema-version";
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
/**
|
|
@@ -14,6 +20,13 @@ import type { CollectionConfig } from "@rebasepro/types";
|
|
|
14
20
|
export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: CollectionConfig): Promise<void> {
|
|
15
21
|
logger.info("🔍 Checking auth tables...");
|
|
16
22
|
|
|
23
|
+
// Before anything else, and deliberately outside the catch below: refuse to
|
|
24
|
+
// run against a database that a newer framework version has already
|
|
25
|
+
// migrated. Everything past this point is best-effort by design, which is
|
|
26
|
+
// exactly the wrong posture for an incompatibility that would otherwise
|
|
27
|
+
// surface as a fully booted server failing every login.
|
|
28
|
+
await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
|
|
29
|
+
|
|
17
30
|
try {
|
|
18
31
|
// Resolve dynamic user table name and ID type from the collection
|
|
19
32
|
let usersTableName = '"rebase"."users"';
|
|
@@ -588,8 +601,18 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
588
601
|
);
|
|
589
602
|
}
|
|
590
603
|
|
|
604
|
+
// Stamped last, so a boot that died partway through the migrations above
|
|
605
|
+
// leaves the older stamp in place and the next boot runs them again.
|
|
606
|
+
await stampAuthSchemaVersion(db, authSchema);
|
|
607
|
+
|
|
591
608
|
logger.info("✅ Auth tables ready");
|
|
592
609
|
} catch (error) {
|
|
610
|
+
// The one failure that must not be survived. Continuing here is what
|
|
611
|
+
// produced a server that answered /health with 200 while every login
|
|
612
|
+
// returned 500 — the incompatibility is total, so crashing is the
|
|
613
|
+
// kinder outcome: an orchestrator will not route traffic to a pod that
|
|
614
|
+
// never came up.
|
|
615
|
+
if (error instanceof AuthSchemaVersionError) throw error;
|
|
593
616
|
logger.error("❌ Failed to create auth tables", { error });
|
|
594
617
|
logger.warn("⚠️ Continuing without creating auth tables.");
|
|
595
618
|
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import type { CollectionConfig } from "@rebasepro/types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The auth schema version this runtime expects to find in the database.
|
|
7
|
+
*
|
|
8
|
+
* Bump this whenever a migration in `ensureAuthTablesExist` makes the schema
|
|
9
|
+
* unreadable by the runtime that came before it — that is, whenever a *previous*
|
|
10
|
+
* version's auth queries would break against the migrated shape. Additive
|
|
11
|
+
* changes (a new nullable column nobody older references) do not need a bump.
|
|
12
|
+
*
|
|
13
|
+
* History. Note that 1 is a label for an era, not a value any database holds:
|
|
14
|
+
* stamping did not exist then, so an era-1 database reads as unstamped
|
|
15
|
+
* (`null`), and 2 is the first version ever actually written. The numbering
|
|
16
|
+
* starts at 2 only because two schema eras already existed when it was
|
|
17
|
+
* introduced; it could just as well have started at 1. It is not worth
|
|
18
|
+
* renumbering now — deployed databases already carry 2, and lowering the
|
|
19
|
+
* constant would make them look newer than the runtime and refuse the boot.
|
|
20
|
+
*
|
|
21
|
+
* 1 — Device-session refresh tokens. A row *was* a session, identified by
|
|
22
|
+
* `unique_device_session UNIQUE (uid, user_agent, ip_address)`, and
|
|
23
|
+
* `createToken` upserted with `ON CONFLICT (uid, user_agent, ip_address)`.
|
|
24
|
+
* 2 — Session-scoped, rotation-safe refresh tokens: `session_id`, `revoked`,
|
|
25
|
+
* `rotated_at`, `session_started_at`, and `unique_device_session`
|
|
26
|
+
* dropped because two live tokens of one session share all three columns.
|
|
27
|
+
*
|
|
28
|
+
* The 1 → 2 migration is why this file exists. Dropping the constraint is
|
|
29
|
+
* one-way: a version-1 runtime deployed afterwards boots perfectly, logs
|
|
30
|
+
* `✅ Auth tables ready` (its `CREATE TABLE IF NOT EXISTS` never revisits the
|
|
31
|
+
* existing table, so it cannot re-add the constraint), answers `/health` with
|
|
32
|
+
* 200 — and then fails every single login and refresh with SQLSTATE 42P10,
|
|
33
|
+
* because its `ON CONFLICT` names a constraint that no longer exists. A silent
|
|
34
|
+
* total auth outage behind a green health check. The stamp below turns that
|
|
35
|
+
* into a boot refusal.
|
|
36
|
+
*/
|
|
37
|
+
export const AUTH_SCHEMA_VERSION = 2;
|
|
38
|
+
|
|
39
|
+
/** Key under which the version is stored in the auth schema's meta table. */
|
|
40
|
+
const VERSION_KEY = "auth_schema_version";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Columns `refresh_tokens` must have for the current runtime's auth write path
|
|
44
|
+
* to work. Checked by the health probe so a database that drifted *below* this
|
|
45
|
+
* runtime is reported as unhealthy rather than discovered one failed login at a
|
|
46
|
+
* time. Kept in step with the migration in `ensureAuthTablesExist`.
|
|
47
|
+
*/
|
|
48
|
+
const REQUIRED_REFRESH_TOKEN_COLUMNS = ["session_id", "revoked", "rotated_at", "session_started_at"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A constraint whose *presence* means the database is still at version 1, in a
|
|
52
|
+
* shape this runtime's rotation logic cannot write to: it makes two live tokens
|
|
53
|
+
* of one rotating session collide.
|
|
54
|
+
*/
|
|
55
|
+
const RETIRED_REFRESH_TOKEN_CONSTRAINT = "unique_device_session";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Thrown when the database was migrated by a runtime newer than this one.
|
|
59
|
+
*
|
|
60
|
+
* Distinct class rather than a bare `Error` because `ensureAuthTablesExist`
|
|
61
|
+
* wraps its migrations in a catch that deliberately swallows failures and
|
|
62
|
+
* continues — every other problem there is better survived than crashed on.
|
|
63
|
+
* This one is not, so the catch rethrows on this type specifically.
|
|
64
|
+
*/
|
|
65
|
+
export class AuthSchemaVersionError extends Error {
|
|
66
|
+
readonly databaseVersion: number;
|
|
67
|
+
readonly runtimeVersion: number;
|
|
68
|
+
|
|
69
|
+
constructor(databaseVersion: number, runtimeVersion: number) {
|
|
70
|
+
super(
|
|
71
|
+
`Auth schema version mismatch: the database is at version ${databaseVersion}, ` +
|
|
72
|
+
`but this runtime understands version ${runtimeVersion}.\n\n` +
|
|
73
|
+
"A newer version of the framework has already migrated this database. Running this " +
|
|
74
|
+
"older runtime against it would boot cleanly and then fail every login and token " +
|
|
75
|
+
"refresh, because the auth schema it expects no longer exists.\n\n" +
|
|
76
|
+
"Refusing to start. Deploy a framework version at or above the one that migrated " +
|
|
77
|
+
"this database, or restore the database from a backup taken before the upgrade."
|
|
78
|
+
);
|
|
79
|
+
this.name = "AuthSchemaVersionError";
|
|
80
|
+
this.databaseVersion = databaseVersion;
|
|
81
|
+
this.runtimeVersion = runtimeVersion;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`
|
|
87
|
+
* derives it. Shared so the two cannot drift: a stamp written to one schema and
|
|
88
|
+
* read from another would read as "never stamped" forever.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveAuthSchema(collection?: CollectionConfig): string {
|
|
91
|
+
if (!collection) return "rebase";
|
|
92
|
+
const usersSchema = ("schema" in collection && typeof collection.schema === "string")
|
|
93
|
+
? collection.schema
|
|
94
|
+
: "public";
|
|
95
|
+
return usersSchema === "public" ? "rebase" : usersSchema;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read the stamped version, or `null` when the database has never been stamped.
|
|
100
|
+
*
|
|
101
|
+
* `null` is not an error and must not be treated as one: every database
|
|
102
|
+
* provisioned before this file existed is unstamped, and so is every fresh one.
|
|
103
|
+
* Uses `to_regclass` rather than selecting straight from the table so a missing
|
|
104
|
+
* schema or table is a `null` rather than a thrown 42P01.
|
|
105
|
+
*/
|
|
106
|
+
export async function readAuthSchemaVersion(
|
|
107
|
+
db: NodePgDatabase,
|
|
108
|
+
authSchema: string
|
|
109
|
+
): Promise<number | null> {
|
|
110
|
+
const qualified = `"${authSchema}"."schema_meta"`;
|
|
111
|
+
const exists = await db.execute(sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`);
|
|
112
|
+
if (!(exists.rows[0] as { present: boolean } | undefined)?.present) return null;
|
|
113
|
+
|
|
114
|
+
const result = await db.execute(sql`
|
|
115
|
+
SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
|
|
116
|
+
`);
|
|
117
|
+
const raw = (result.rows[0] as { value: string } | undefined)?.value;
|
|
118
|
+
if (raw === undefined) return null;
|
|
119
|
+
|
|
120
|
+
const parsed = Number.parseInt(raw, 10);
|
|
121
|
+
// A meta row we cannot parse is treated as unstamped rather than as version
|
|
122
|
+
// 0: refusing to boot over a garbled string would be a worse failure than
|
|
123
|
+
// the drift it is meant to catch.
|
|
124
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Refuse to run against a database a newer runtime has already migrated.
|
|
129
|
+
*
|
|
130
|
+
* Deliberately one-directional. A database *older* than this runtime is the
|
|
131
|
+
* normal upgrade path — the migrations in `ensureAuthTablesExist` are about to
|
|
132
|
+
* bring it forward, so it is not an error. Only the reverse is unrecoverable.
|
|
133
|
+
*/
|
|
134
|
+
export async function assertAuthSchemaCompatible(
|
|
135
|
+
db: NodePgDatabase,
|
|
136
|
+
authSchema: string
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const databaseVersion = await readAuthSchemaVersion(db, authSchema);
|
|
139
|
+
if (databaseVersion !== null && databaseVersion > AUTH_SCHEMA_VERSION) {
|
|
140
|
+
throw new AuthSchemaVersionError(databaseVersion, AUTH_SCHEMA_VERSION);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Record that this runtime's migrations have been applied.
|
|
146
|
+
*
|
|
147
|
+
* Called at the end of `ensureAuthTablesExist`, so a boot that failed partway
|
|
148
|
+
* through leaves the older stamp in place and the next boot migrates again.
|
|
149
|
+
*/
|
|
150
|
+
export async function stampAuthSchemaVersion(
|
|
151
|
+
db: NodePgDatabase,
|
|
152
|
+
authSchema: string
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const qualified = `"${authSchema}"."schema_meta"`;
|
|
155
|
+
await db.execute(sql`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
|
|
157
|
+
key TEXT PRIMARY KEY,
|
|
158
|
+
value TEXT NOT NULL,
|
|
159
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
160
|
+
)
|
|
161
|
+
`);
|
|
162
|
+
await db.execute(sql`
|
|
163
|
+
INSERT INTO ${sql.raw(qualified)} (key, value)
|
|
164
|
+
VALUES (${VERSION_KEY}, ${String(AUTH_SCHEMA_VERSION)})
|
|
165
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
|
166
|
+
`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What {@link probeAuthSchema} found. */
|
|
170
|
+
export interface AuthSchemaProbeResult {
|
|
171
|
+
/** False when this runtime cannot be trusted to serve auth against this database. */
|
|
172
|
+
healthy: boolean;
|
|
173
|
+
/** The stamped version, or `null` on a database that predates stamping. */
|
|
174
|
+
databaseVersion: number | null;
|
|
175
|
+
/** {@link AUTH_SCHEMA_VERSION}. */
|
|
176
|
+
runtimeVersion: number;
|
|
177
|
+
/** Human-readable descriptions of each mismatch found. Empty when healthy. */
|
|
178
|
+
problems: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check that the auth schema is one this runtime can actually write to.
|
|
183
|
+
*
|
|
184
|
+
* Two independent checks, because either alone has a blind spot:
|
|
185
|
+
*
|
|
186
|
+
* - The **stamp** catches a runtime older than the database. It is the precise
|
|
187
|
+
* signal, but it is blind on every database provisioned before stamping
|
|
188
|
+
* existed — which today is all of them.
|
|
189
|
+
* - The **structure** catches a database older than the runtime, and works on
|
|
190
|
+
* unstamped databases. It is what makes this useful immediately rather than
|
|
191
|
+
* one upgrade cycle from now.
|
|
192
|
+
*
|
|
193
|
+
* Never throws: a probe that fails to run reports unhealthy with the reason, so
|
|
194
|
+
* a broken check surfaces as a degraded health response rather than a 500 from
|
|
195
|
+
* the health endpoint itself.
|
|
196
|
+
*/
|
|
197
|
+
export async function probeAuthSchema(
|
|
198
|
+
db: NodePgDatabase,
|
|
199
|
+
authSchema: string
|
|
200
|
+
): Promise<AuthSchemaProbeResult> {
|
|
201
|
+
const problems: string[] = [];
|
|
202
|
+
let databaseVersion: number | null = null;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
databaseVersion = await readAuthSchemaVersion(db, authSchema);
|
|
206
|
+
if (databaseVersion !== null && databaseVersion > AUTH_SCHEMA_VERSION) {
|
|
207
|
+
problems.push(
|
|
208
|
+
`database is at auth schema version ${databaseVersion}, this runtime understands ` +
|
|
209
|
+
`${AUTH_SCHEMA_VERSION} — it was migrated by a newer framework version`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const refreshTokens = `"${authSchema}"."refresh_tokens"`;
|
|
214
|
+
const present = await db.execute(sql`SELECT to_regclass(${refreshTokens}) IS NOT NULL AS present`);
|
|
215
|
+
if (!(present.rows[0] as { present: boolean } | undefined)?.present) {
|
|
216
|
+
// Not a problem in itself: auth may simply not be configured on this
|
|
217
|
+
// deployment, and the table is created on demand at boot when it is.
|
|
218
|
+
return { healthy: problems.length === 0, databaseVersion, runtimeVersion: AUTH_SCHEMA_VERSION, problems };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const columns = await db.execute(sql`
|
|
222
|
+
SELECT column_name FROM information_schema.columns
|
|
223
|
+
WHERE table_schema = ${authSchema} AND table_name = 'refresh_tokens'
|
|
224
|
+
`);
|
|
225
|
+
const found = new Set((columns.rows as { column_name: string }[]).map(row => row.column_name));
|
|
226
|
+
const missing = REQUIRED_REFRESH_TOKEN_COLUMNS.filter(column => !found.has(column));
|
|
227
|
+
if (missing.length > 0) {
|
|
228
|
+
problems.push(
|
|
229
|
+
`refresh_tokens is missing ${missing.join(", ")} — the auth migrations have not been ` +
|
|
230
|
+
"applied to this database, so token rotation will fail"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const retired = await db.execute(sql`
|
|
235
|
+
SELECT 1 FROM pg_constraint c
|
|
236
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
237
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
238
|
+
WHERE n.nspname = ${authSchema}
|
|
239
|
+
AND t.relname = 'refresh_tokens'
|
|
240
|
+
AND c.conname = ${RETIRED_REFRESH_TOKEN_CONSTRAINT}
|
|
241
|
+
`);
|
|
242
|
+
if (retired.rows.length > 0) {
|
|
243
|
+
problems.push(
|
|
244
|
+
`refresh_tokens still carries ${RETIRED_REFRESH_TOKEN_CONSTRAINT} — concurrent token ` +
|
|
245
|
+
"rotation for one session will fail on it"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} catch (error: unknown) {
|
|
249
|
+
problems.push(
|
|
250
|
+
`auth schema probe failed: ${error instanceof Error ? error.message : String(error)}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
healthy: problems.length === 0,
|
|
256
|
+
databaseVersion,
|
|
257
|
+
runtimeVersion: AUTH_SCHEMA_VERSION,
|
|
258
|
+
problems
|
|
259
|
+
};
|
|
260
|
+
}
|
package/src/cli-errors.ts
CHANGED
|
@@ -113,8 +113,8 @@ function formatConnectionRefusedBanner(databaseUrl: string): string {
|
|
|
113
113
|
` The database server is not running or is not accepting\n` +
|
|
114
114
|
` connections. Common fixes:\n` +
|
|
115
115
|
`\n` +
|
|
116
|
+
` • docker compose up -d db (the service a Rebase scaffold ships)\n` +
|
|
116
117
|
` • brew services start postgresql@18\n` +
|
|
117
|
-
` • docker compose up -d postgres\n` +
|
|
118
118
|
` • Verify DATABASE_URL in your .env file\n` +
|
|
119
119
|
`\n` +
|
|
120
120
|
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
package/src/cli-helpers.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { isManyToMany } from "@rebasepro/types";
|
|
1
2
|
import path from "path";
|
|
2
3
|
import fs from "fs";
|
|
3
4
|
import { execSync } from "child_process";
|
|
4
5
|
import { pathToFileURL } from "url";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { logger } from "@rebasepro/server";
|
|
7
|
-
import type { CollectionConfig,
|
|
8
|
+
import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
8
9
|
import { moduleDir as __helpersDirname } from "./module-dir";
|
|
9
10
|
|
|
10
11
|
|
|
@@ -53,8 +54,8 @@ export async function getTableIncludesFromCollections(collections: CollectionCon
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
const resolvedRelations = resolveCollectionRelations(col);
|
|
56
|
-
for (const relation of Object.values(resolvedRelations) as
|
|
57
|
-
if (relation
|
|
57
|
+
for (const relation of Object.values(resolvedRelations) as ResolvedRelation[]) {
|
|
58
|
+
if (isManyToMany(relation)) {
|
|
58
59
|
const junctionTableName = relation.through.table;
|
|
59
60
|
const targetCollection = relation.target();
|
|
60
61
|
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CollectionRegistry } from "@rebasepro/common";
|
|
2
|
-
import { type CollectionConfig
|
|
1
|
+
import { CollectionRegistry, resolveCollectionRelations } from "@rebasepro/common";
|
|
2
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
3
3
|
import { PgEnum, PgTable } from "drizzle-orm/pg-core";
|
|
4
4
|
import { Relations } from "drizzle-orm";
|
|
5
5
|
import { CollectionRegistryInterface } from "../interfaces";
|
|
@@ -95,8 +95,13 @@ export class PostgresCollectionRegistry extends CollectionRegistry implements Co
|
|
|
95
95
|
*/
|
|
96
96
|
getRelationKeysForCollection(collectionPath: string): string[] {
|
|
97
97
|
const collection = this.getCollectionByPath(collectionPath);
|
|
98
|
-
if (!collection
|
|
99
|
-
|
|
98
|
+
if (!collection) return [];
|
|
99
|
+
// Resolved, not authored. `relationName` is optional at the authoring
|
|
100
|
+
// surface and defaults to the property key or the target's slug, so
|
|
101
|
+
// reading the raw field dropped every relation that relied on the
|
|
102
|
+
// default — and never saw relations declared inline on a property at
|
|
103
|
+
// all, since those are not in the `relations` array.
|
|
104
|
+
return Object.keys(resolveCollectionRelations(collection));
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
}
|
|
@@ -4,6 +4,7 @@ import { CollectionConfig } from "@rebasepro/types";
|
|
|
4
4
|
import { logger } from "@rebasepro/server";
|
|
5
5
|
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
6
6
|
import { warnOnKeysTheAdminCannotResolve } from "../services/collection-helpers";
|
|
7
|
+
import { assertRelationsResolve } from "./validate-relations";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Everything a registry is built from: the collections, and the drizzle schema
|
|
@@ -55,5 +56,11 @@ export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollect
|
|
|
55
56
|
// schema, and nothing serves it one, so only an edit to the config fixes it.
|
|
56
57
|
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
57
58
|
|
|
59
|
+
// And now that the tables resolve: refuse to start on a relation whose
|
|
60
|
+
// names do not exist. The union checks a relation's shape at compile time;
|
|
61
|
+
// only here is there a schema to check its *names* against. Every one of
|
|
62
|
+
// these used to surface as an empty result at query time and nothing else.
|
|
63
|
+
assertRelationsResolve(registry.getCollections(), registry);
|
|
64
|
+
|
|
58
65
|
return registry;
|
|
59
66
|
}
|