@rebasepro/server-postgres 0.10.1-canary.d8d45b2 → 0.10.1-canary.ed78a2c
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/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +913 -391
- package/dist/index.es.js.map +1 -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-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
- 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 +15 -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/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +28 -21
- 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
- package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
- package/dist/src-B0v4IKaI.js.map +0 -1
- package/dist/src-DmsRg8MR.js.map +0 -1
|
@@ -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
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { getTableColumns } from "drizzle-orm";
|
|
2
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
4
|
+
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
|
|
6
|
+
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check every relation against the schema it actually runs on, at boot.
|
|
10
|
+
*
|
|
11
|
+
* The tagged union made the *shape* of a relation impossible to get wrong: a
|
|
12
|
+
* `manyToMany` cannot carry a `foreignKeyOnTarget`, a to-many cannot carry a
|
|
13
|
+
* `localKey`. What it cannot know is whether any of the names are real —
|
|
14
|
+
* whether `posts_tags` is a table, whether `author_id` is a column, whether a
|
|
15
|
+
* `joinPath` connects the tables it claims to. Those are facts about the
|
|
16
|
+
* database, and the type system never sees them.
|
|
17
|
+
*
|
|
18
|
+
* Until now nothing checked them until a query ran, and the failures were the
|
|
19
|
+
* quiet kind. A missing junction table logged a warning and returned no rows,
|
|
20
|
+
* so `posts/1/tags` answered `[]` — indistinguishable from a post with no tags.
|
|
21
|
+
* The relation looked configured, the admin drew the tab, the tab was empty,
|
|
22
|
+
* and nothing anywhere said why.
|
|
23
|
+
*
|
|
24
|
+
* The junction default is the sharp edge this exists for. `through.table`
|
|
25
|
+
* defaults to the two table names sorted and joined, so renaming a table
|
|
26
|
+
* silently re-points the relation at a name that was never created. It is the
|
|
27
|
+
* one default whose output changes when you edit something that looks
|
|
28
|
+
* unrelated.
|
|
29
|
+
*/
|
|
30
|
+
export interface RelationDefect {
|
|
31
|
+
/** Slug of the collection declaring the relation. */
|
|
32
|
+
collection: string;
|
|
33
|
+
relationName: string;
|
|
34
|
+
kind: ResolvedRelation["kind"];
|
|
35
|
+
/** What is wrong, in terms of the schema. */
|
|
36
|
+
problem: string;
|
|
37
|
+
/** The edit that fixes it. */
|
|
38
|
+
fix: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every name a column answers to: the key in the drizzle schema and the real
|
|
43
|
+
* column name in Postgres. A relation may legitimately be written with either,
|
|
44
|
+
* and reporting a working relation as broken is worse than not checking.
|
|
45
|
+
*/
|
|
46
|
+
function columnNames(table: PgTable): Set<string> {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
for (const [key, col] of Object.entries(getTableColumns(table))) {
|
|
49
|
+
names.add(key);
|
|
50
|
+
const dbName = (col as { name?: string })?.name;
|
|
51
|
+
if (dbName) names.add(dbName);
|
|
52
|
+
}
|
|
53
|
+
return names;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join(", ");
|
|
57
|
+
|
|
58
|
+
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
59
|
+
const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Relations whose names do not resolve against the registered schema.
|
|
63
|
+
*
|
|
64
|
+
* Fails open wherever it cannot see enough to be sure — an unregistered source
|
|
65
|
+
* table, a target belonging to another backend — because a false alarm here
|
|
66
|
+
* costs more than a missed one: it would block boot on a working app.
|
|
67
|
+
*/
|
|
68
|
+
export function findRelationDefects(
|
|
69
|
+
collections: CollectionConfig[],
|
|
70
|
+
registry: PostgresCollectionRegistry
|
|
71
|
+
): RelationDefect[] {
|
|
72
|
+
const defects: RelationDefect[] = [];
|
|
73
|
+
const registeredSlugs = new Set(registry.getCollections().map(c => c.slug));
|
|
74
|
+
|
|
75
|
+
for (const collection of collections) {
|
|
76
|
+
const sourceTableName = getTableName(collection);
|
|
77
|
+
const sourceTable = registry.getTable(sourceTableName);
|
|
78
|
+
// Nothing to check against. Another boot warning already covers this.
|
|
79
|
+
if (!sourceTable) continue;
|
|
80
|
+
|
|
81
|
+
const sourceColumns = columnNames(sourceTable);
|
|
82
|
+
const relations = resolveCollectionRelations(collection);
|
|
83
|
+
|
|
84
|
+
for (const relation of Object.values(relations)) {
|
|
85
|
+
const at = { collection: collection.slug,
|
|
86
|
+
relationName: relation.relationName,
|
|
87
|
+
kind: relation.kind };
|
|
88
|
+
|
|
89
|
+
let targetCollection: CollectionConfig;
|
|
90
|
+
try {
|
|
91
|
+
targetCollection = relation.target();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
defects.push({
|
|
94
|
+
...at,
|
|
95
|
+
problem: `its \`target()\` threw: ${e instanceof Error ? e.message : String(e)}`,
|
|
96
|
+
fix: "a target thunk usually throws because of a circular import — make sure it is `() => otherCollection` and not evaluated at module load"
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A target this registry has never heard of belongs to another
|
|
102
|
+
// backend; its tables are not ours to check.
|
|
103
|
+
if (!registeredSlugs.has(targetCollection.slug)) continue;
|
|
104
|
+
|
|
105
|
+
const targetTableName = getTableName(targetCollection);
|
|
106
|
+
const targetTable = registry.getTable(targetTableName);
|
|
107
|
+
if (!targetTable) {
|
|
108
|
+
defects.push({
|
|
109
|
+
...at,
|
|
110
|
+
problem: `it points at collection \`${targetCollection.slug}\`, which has no table \`${targetTableName}\` in the schema`,
|
|
111
|
+
fix: `create the \`${targetTableName}\` table, or correct \`table\` on the \`${targetCollection.slug}\` collection`
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const targetColumns = columnNames(targetTable);
|
|
116
|
+
|
|
117
|
+
switch (relation.kind) {
|
|
118
|
+
case "belongsTo": {
|
|
119
|
+
if (!sourceColumns.has(relation.localKey)) {
|
|
120
|
+
defects.push({
|
|
121
|
+
...at,
|
|
122
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
123
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "hasOne":
|
|
130
|
+
case "hasMany": {
|
|
131
|
+
if (!targetColumns.has(relation.foreignKeyOnTarget)) {
|
|
132
|
+
defects.push({
|
|
133
|
+
...at,
|
|
134
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
135
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "manyToMany": {
|
|
142
|
+
const { table, sourceColumn, targetColumn } = relation.through;
|
|
143
|
+
const junction = registry.getTable(table);
|
|
144
|
+
if (!junction) {
|
|
145
|
+
defects.push({
|
|
146
|
+
...at,
|
|
147
|
+
problem: `its junction table \`${table}\` does not exist`,
|
|
148
|
+
fix: `create \`${table}\`, or name the real one with \`through: { table: "..." }\`. ` +
|
|
149
|
+
"Note that an omitted `through.table` is derived from the two table names sorted " +
|
|
150
|
+
"and joined, so renaming a table changes it"
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
const junctionColumns = columnNames(junction);
|
|
155
|
+
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
|
|
156
|
+
if (!junctionColumns.has(column)) {
|
|
157
|
+
defects.push({
|
|
158
|
+
...at,
|
|
159
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
160
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
|
|
161
|
+
(label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case "via": {
|
|
169
|
+
if (relation.joinPath.length === 0) {
|
|
170
|
+
defects.push({
|
|
171
|
+
...at,
|
|
172
|
+
problem: "its `joinPath` is empty, so it joins nothing",
|
|
173
|
+
fix: "add at least one step, ending at the target's table"
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Walk the chain: each step's `from` names columns on the
|
|
179
|
+
// previous table, its `to` names columns on its own.
|
|
180
|
+
let prevName = sourceTableName;
|
|
181
|
+
let prevColumns = sourceColumns;
|
|
182
|
+
let broken = false;
|
|
183
|
+
|
|
184
|
+
for (const [i, step] of relation.joinPath.entries()) {
|
|
185
|
+
const stepTable = registry.getTable(step.table);
|
|
186
|
+
if (!stepTable) {
|
|
187
|
+
defects.push({
|
|
188
|
+
...at,
|
|
189
|
+
problem: `step ${i + 1} of its \`joinPath\` joins \`${step.table}\`, which is not a table in the schema`,
|
|
190
|
+
fix: `correct \`joinPath[${i}].table\``
|
|
191
|
+
});
|
|
192
|
+
broken = true;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
const stepColumns = columnNames(stepTable);
|
|
196
|
+
|
|
197
|
+
for (const column of asColumns(step.on.from)) {
|
|
198
|
+
if (!prevColumns.has(column)) {
|
|
199
|
+
defects.push({
|
|
200
|
+
...at,
|
|
201
|
+
problem: `step ${i + 1} joins \`${prevName}.${column}\` → \`${step.table}\`, but \`${column}\` is not a column on \`${prevName}\``,
|
|
202
|
+
fix: `\`joinPath[${i}].on.from\` names columns on ${i === 0 ? "this collection's table" : `the previous step's table (\`${prevName}\`)`}: ${quote(prevColumns)}`
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const column of asColumns(step.on.to)) {
|
|
207
|
+
if (!stepColumns.has(column)) {
|
|
208
|
+
defects.push({
|
|
209
|
+
...at,
|
|
210
|
+
problem: `step ${i + 1} joins into \`${step.table}.${column}\`, but \`${column}\` is not a column on \`${step.table}\``,
|
|
211
|
+
fix: `\`joinPath[${i}].on.to\` names columns on \`${step.table}\`: ${quote(stepColumns)}`
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (asColumns(step.on.from).length !== asColumns(step.on.to).length) {
|
|
217
|
+
defects.push({
|
|
218
|
+
...at,
|
|
219
|
+
problem: `step ${i + 1} compares ${asColumns(step.on.from).length} column(s) against ${asColumns(step.on.to).length}`,
|
|
220
|
+
fix: `\`from\` and \`to\` must name the same number of columns in \`joinPath[${i}]\``
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
prevName = step.table;
|
|
225
|
+
prevColumns = stepColumns;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The chain has to end where the relation says it points,
|
|
229
|
+
// or the rows it returns are not the target's rows.
|
|
230
|
+
if (!broken && prevName !== targetTableName) {
|
|
231
|
+
defects.push({
|
|
232
|
+
...at,
|
|
233
|
+
problem: `its \`joinPath\` ends at \`${prevName}\`, but it targets \`${targetCollection.slug}\` (table \`${targetTableName}\`)`,
|
|
234
|
+
fix: `make the last step join \`${targetTableName}\`, or point \`target\` at the collection backed by \`${prevName}\``
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
default: {
|
|
241
|
+
const exhaustive: never = relation;
|
|
242
|
+
throw new Error(`Unhandled relation kind: ${JSON.stringify(exhaustive)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return defects;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fail boot on any relation that cannot resolve, listing all of them at once.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately fatal rather than a warning. Every one of these produces an
|
|
255
|
+
* empty result at query time and nothing else — an empty tab, an empty
|
|
256
|
+
* `include`, a subcollection that looks like it has no rows. A server that
|
|
257
|
+
* refuses to start is recoverable in a minute; a relation that quietly answers
|
|
258
|
+
* "nothing" is the kind of bug found in production, weeks later, by a user
|
|
259
|
+
* asking where their data went.
|
|
260
|
+
*/
|
|
261
|
+
export function assertRelationsResolve(
|
|
262
|
+
collections: CollectionConfig[],
|
|
263
|
+
registry: PostgresCollectionRegistry
|
|
264
|
+
): void {
|
|
265
|
+
const defects = findRelationDefects(collections, registry);
|
|
266
|
+
if (defects.length === 0) return;
|
|
267
|
+
|
|
268
|
+
const lines = defects.map(d =>
|
|
269
|
+
` • ${d.collection}.${d.relationName} (${d.kind})\n` +
|
|
270
|
+
` ${d.problem}\n` +
|
|
271
|
+
` fix: ${d.fix}`
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
throw new Error(
|
|
275
|
+
`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\n` +
|
|
276
|
+
"Each of these would return no rows at query time rather than reporting an error, " +
|
|
277
|
+
"so they are fatal at boot instead.\n\n" +
|
|
278
|
+
lines.join("\n\n") + "\n"
|
|
279
|
+
);
|
|
280
|
+
}
|