@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.
Files changed (45) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/collections/validate-relations.d.ts +53 -0
  3. package/dist/data-transformer.d.ts +3 -3
  4. package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +913 -391
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/FetchService.d.ts +21 -8
  9. package/dist/services/PersistService.d.ts +12 -0
  10. package/dist/services/RelationService.d.ts +39 -8
  11. package/dist/services/cdc/junction-tables.d.ts +38 -0
  12. package/dist/services/nested-path.d.ts +59 -0
  13. package/dist/services/realtimeService.d.ts +19 -0
  14. package/dist/services/row-pipeline.d.ts +2 -2
  15. package/dist/{src-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
  16. package/dist/src-3VmUJ8Xn.js.map +1 -0
  17. package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
  18. package/dist/src-D5xBTl32.js.map +1 -0
  19. package/dist/utils/drizzle-conditions.d.ts +71 -18
  20. package/package.json +8 -9
  21. package/src/PostgresBootstrapper.ts +15 -2
  22. package/src/auth/ensure-tables.ts +23 -0
  23. package/src/auth/schema-version.ts +260 -0
  24. package/src/cli-errors.ts +1 -1
  25. package/src/cli-helpers.ts +4 -3
  26. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  27. package/src/collections/buildRegistry.ts +7 -0
  28. package/src/collections/validate-relations.ts +280 -0
  29. package/src/data-transformer.ts +28 -38
  30. package/src/schema/doctor.ts +14 -14
  31. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  32. package/src/schema/generate-postgres-ddl-logic.ts +28 -21
  33. package/src/schema/introspect-db-inference.ts +13 -13
  34. package/src/schema/introspect-db-logic.ts +25 -29
  35. package/src/services/FetchService.ts +116 -126
  36. package/src/services/PersistService.ts +126 -88
  37. package/src/services/RelationService.ts +157 -86
  38. package/src/services/cdc/junction-tables.ts +91 -0
  39. package/src/services/nested-path.ts +145 -0
  40. package/src/services/realtimeService.ts +60 -0
  41. package/src/services/row-pipeline.ts +5 -6
  42. package/src/utils/drizzle-conditions.ts +268 -330
  43. package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
  44. package/dist/src-B0v4IKaI.js.map +0 -1
  45. 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`
@@ -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, Relation } from "@rebasepro/types";
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 Relation[]) {
57
- if (relation.through) {
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, type Relation, getDataSourceCapabilities } from "@rebasepro/types";
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 || !getDataSourceCapabilities(collection.engine).supportsRelations || !collection.relations) return [];
99
- return collection.relations!.map((r: Relation) => r.relationName || r.localKey || "").filter(Boolean);
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
+ }