@rebasepro/server-postgres 0.17.3 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,108 +0,0 @@
1
- import { CollectionRegistry, resolveCollectionRelations } from "@rebasepro/common";
2
- import { type CollectionConfig } from "@rebasepro/types";
3
- import { PgEnum, PgTable } from "drizzle-orm/pg-core";
4
- import { Relations } from "drizzle-orm";
5
- import { CollectionRegistryInterface } from "../interfaces";
6
- import { getTableName } from "@rebasepro/common";
7
-
8
- /**
9
- * PostgreSQL-specific collection registry.
10
- * Extends the base CollectionRegistry with support for Drizzle ORM tables, enums, and relations.
11
- *
12
- * Satisfies CollectionRegistryInterface through inheritance from CollectionRegistry.
13
- */
14
- export class PostgresCollectionRegistry extends CollectionRegistry implements CollectionRegistryInterface {
15
-
16
- private tables = new Map<string, PgTable>();
17
- private enums = new Map<string, PgEnum<[string, ...string[]]>>();
18
- private relations = new Map<string, Relations>();
19
-
20
- registerTable(table: PgTable, tableName: string) {
21
- this.tables.set(tableName, table);
22
- }
23
-
24
- getTable(tableName: string): PgTable | undefined {
25
- return this.tables.get(tableName);
26
- }
27
-
28
- /**
29
- * Checks if a specific collection has a registered table
30
- */
31
- hasTableForCollection(tableName: string): boolean {
32
- return this.tables.has(tableName);
33
- }
34
-
35
- /**
36
- * Returns all registered table names.
37
- */
38
- getTableNames(): string[] {
39
- return Array.from(this.tables.keys());
40
- }
41
-
42
- /**
43
- * Finds collections assigned to a specific data source that do not have a registered table.
44
- */
45
- getCollectionsWithoutTables(dataSourceKey = "(default)"): CollectionConfig[] {
46
- const collections = this.getCollections().filter(
47
- c => c.dataSource === dataSourceKey || (!c.dataSource && dataSourceKey === "(default)")
48
- );
49
- return collections.filter(c => !this.tables.has(getTableName(c)));
50
- }
51
-
52
- registerEnums(enums: Record<string, PgEnum<[string, ...string[]]>>) {
53
- Object.entries(enums).forEach(([name, value]) => this.enums.set(name, value));
54
- }
55
-
56
- registerRelations(relations: Record<string, Relations>) {
57
- Object.entries(relations).forEach(([name, value]) => this.relations.set(name, value));
58
- }
59
-
60
- getEnum(name: string): PgEnum<[string, ...string[]]> | undefined {
61
- return this.enums.get(name);
62
- }
63
-
64
- getRelation(name: string): Relations | undefined {
65
- return this.relations.get(name);
66
- }
67
-
68
- getAllEnums(): Record<string, PgEnum<[string, ...string[]]>> {
69
- return Object.fromEntries(this.enums.entries());
70
- }
71
-
72
- getAllRelations(): Record<string, Relations> {
73
- return Object.fromEntries(this.relations.entries());
74
- }
75
-
76
- /**
77
- * Get the merged schema object (tables + relations) for use with Drizzle's
78
- * relational query API (`db.query`).
79
- */
80
- getMergedSchema(): Record<string, unknown> {
81
- const result: Record<string, unknown> = {};
82
- for (const [name, table] of this.tables.entries()) {
83
- result[name] = table;
84
- }
85
- for (const [name, relation] of this.relations.entries()) {
86
- result[name] = relation;
87
- }
88
- return result;
89
- }
90
-
91
- /**
92
- * Get the available Drizzle relation keys for a given collection path.
93
- * Maps from the collection's relation property names to the Drizzle relation names
94
- * defined in the schema.
95
- */
96
- getRelationKeysForCollection(collectionPath: string): string[] {
97
- const collection = this.getCollectionByPath(collectionPath);
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));
105
- }
106
-
107
- }
108
-
@@ -1,68 +0,0 @@
1
- import { isTable, getTableName, Relations } from "drizzle-orm";
2
- import { PgEnum, PgTable } from "drizzle-orm/pg-core";
3
- import { CollectionConfig } from "@rebasepro/types";
4
- import { logger } from "@rebasepro/server";
5
- import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
6
- import { warnOnKeysTheAdminCannotResolve } from "../services/collection-helpers";
7
- import { assertRelationsResolve } from "./validate-relations";
8
-
9
- /**
10
- * Everything a registry is built from: the collections, and the drizzle schema
11
- * they are backed by. In BaaS mode all of it is introspected from the live
12
- * database; when collections are declared it comes from the config and the generated schema.
13
- */
14
- export interface RegistrySchema {
15
- collections?: CollectionConfig[];
16
- tables?: Record<string, unknown>;
17
- enums?: Record<string, PgEnum<[string, ...string[]]>>;
18
- relations?: Record<string, Relations>;
19
- }
20
-
21
- /**
22
- * Build the collection registry for a driver.
23
- *
24
- * The order matters and is the reason this is one function rather than a run of
25
- * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
26
- * anything that inspects them has to run *after* the tables are registered —
27
- * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
28
- * collection whose table it cannot look up is one it has nothing to say about.
29
- * Warned too early, it would skip every collection and report nothing, which
30
- * reads exactly like having nothing to report.
31
- */
32
- export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollectionRegistry {
33
- const registry = new PostgresCollectionRegistry();
34
-
35
- if (schema.collections) {
36
- registry.registerMultiple(schema.collections);
37
- // `Auto-discovered collections` already reports the count and the
38
- // directory they came from; this is the same fact with the names.
39
- logger.debug(
40
- `📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
41
- `[${registry.getCollections().map(c => c.slug).join(", ")}]`
42
- );
43
- }
44
-
45
- if (schema.tables) {
46
- Object.values(schema.tables).forEach((table) => {
47
- if (isTable(table)) {
48
- registry.registerTable(table as PgTable, getTableName(table));
49
- }
50
- });
51
- }
52
-
53
- if (schema.enums) registry.registerEnums(schema.enums);
54
- if (schema.relations) registry.registerRelations(schema.relations);
55
-
56
- // Now that the keys resolve: say which of them the admin cannot see. It
57
- // compiles the same collection files into its bundle but never the drizzle
58
- // schema, and nothing serves it one, so only an edit to the config fixes it.
59
- warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
60
-
61
- // And now that the tables resolve: refuse to start on a relation whose
62
- // names do not exist. The union checks a relation's shape at compile time;
63
- // only here is there a schema to check its *names* against. Every one of
64
- // these used to surface as an empty result at query time and nothing else.
65
- assertRelationsResolve(registry.getCollections(), registry);
66
-
67
- return registry;
68
- }
@@ -1,402 +0,0 @@
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
- import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
6
-
7
- import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
8
-
9
- /**
10
- * Check every relation against the schema it actually runs on, at boot.
11
- *
12
- * The tagged union made the *shape* of a relation impossible to get wrong: a
13
- * `manyToMany` cannot carry a `foreignKeyOnTarget`, a to-many cannot carry a
14
- * `localKey`. What it cannot know is whether any of the names are real —
15
- * whether `posts_tags` is a table, whether `author_id` is a column, whether a
16
- * `joinPath` connects the tables it claims to. Those are facts about the
17
- * database, and the type system never sees them.
18
- *
19
- * Until now nothing checked them until a query ran, and the failures were the
20
- * quiet kind. A missing junction table logged a warning and returned no rows,
21
- * so `posts/1/tags` answered `[]` — indistinguishable from a post with no tags.
22
- * The relation looked configured, the admin drew the tab, the tab was empty,
23
- * and nothing anywhere said why.
24
- *
25
- * The junction default is the sharp edge this exists for. `through.table`
26
- * defaults to the two table names sorted and joined, so renaming a table
27
- * silently re-points the relation at a name that was never created. It is the
28
- * one default whose output changes when you edit something that looks
29
- * unrelated.
30
- */
31
- export interface RelationDefect {
32
- /** Slug of the collection declaring the relation. */
33
- collection: string;
34
- relationName: string;
35
- kind: ResolvedRelation["kind"];
36
- /** What is wrong, in terms of the schema. */
37
- problem: string;
38
- /** The edit that fixes it. */
39
- fix: string;
40
- }
41
-
42
- /**
43
- * Every name a column answers to: the key in the drizzle schema and the real
44
- * column name in Postgres. A relation may legitimately be written with either,
45
- * and reporting a working relation as broken is worse than not checking.
46
- */
47
- function columnNames(table: PgTable): Set<string> {
48
- const names = new Set<string>();
49
- for (const [key, col] of Object.entries(getTableColumns(table))) {
50
- names.add(key);
51
- const dbName = (col as { name?: string })?.name;
52
- if (dbName) names.add(dbName);
53
- }
54
- return names;
55
- }
56
-
57
- const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join(", ");
58
-
59
- /** `on.from` / `on.to` accept a single column or a composite tuple. */
60
- const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
61
-
62
- /**
63
- * Distinguish "this column name is wrong" from "the generated schema is old".
64
- *
65
- * They present identically here — a relation asks for a column the registered
66
- * table does not have — but they are opposite problems with opposite fixes, and
67
- * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
68
- * projects.
69
- *
70
- * The registered table is not the database. It comes from the project's
71
- * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
72
- * derives foreign-key names: `categories` yields `category_id` where it used to
73
- * yield `categorie_id`. Boot-ensure renames the database column to match, so by
74
- * the time this runs the *database* is correct and the *generated module* is the
75
- * stale one. Reporting "not a column" then points at the wrong artifact, and the
76
- * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
77
- * name because that is what the stale module still has — talks the reader into
78
- * pinning a column that no longer exists.
79
- *
80
- * So when the wanted name is what the current rule derives, and the table
81
- * carries what the *previous* rule would have derived from the same source, say
82
- * that instead.
83
- *
84
- * @param wanted the column the relation asks for
85
- * @param available every column the registered table has
86
- * @param sources names the default could have been derived from (a slug, a
87
- * relation name) — checking against these rather than guessing
88
- * backwards from `wanted` keeps the match exact
89
- */
90
- function staleCodegenRename(
91
- wanted: string,
92
- available: Set<string>,
93
- sources: string[]
94
- ): { legacy: string; current: string } | null {
95
- for (const source of sources) {
96
- if (!source) continue;
97
- const current = generateForeignKeyName(source);
98
- const legacy = legacyForeignKeyName(source);
99
- // Only a name that actually moved, and only when the table still has the
100
- // old spelling and not the new one.
101
- if (current !== wanted || legacy === current) continue;
102
- if (available.has(legacy) && !available.has(current)) return { legacy, current };
103
- }
104
- return null;
105
- }
106
-
107
- /** The shared explanation, so every relation kind reports it identically. */
108
- function staleCodegenDefect(
109
- table: string,
110
- { legacy, current }: { legacy: string; current: string }
111
- ): Pick<RelationDefect, "problem" | "fix"> {
112
- return {
113
- problem:
114
- `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this ` +
115
- `release derives \`${current}\` — the generated schema predates the foreign-key ` +
116
- "naming fix and no longer describes the database",
117
- fix:
118
- "regenerate it with `rebase schema generate` (or `pnpm run schema:generate`). The " +
119
- "database column has already been renamed for you at boot, so nothing else is needed. " +
120
- `To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
121
- };
122
- }
123
-
124
- /**
125
- * Relations whose names do not resolve against the registered schema.
126
- *
127
- * Fails open wherever it cannot see enough to be sure — an unregistered source
128
- * table, a target belonging to another backend — because a false alarm here
129
- * costs more than a missed one: it would block boot on a working app.
130
- */
131
- export function findRelationDefects(
132
- collections: CollectionConfig[],
133
- registry: PostgresCollectionRegistry
134
- ): RelationDefect[] {
135
- const defects: RelationDefect[] = [];
136
- const registeredSlugs = new Set(registry.getCollections().map(c => c.slug));
137
-
138
- for (const collection of collections) {
139
- const sourceTableName = getTableName(collection);
140
- const sourceTable = registry.getTable(sourceTableName);
141
- // Nothing to check against. Another boot warning already covers this.
142
- if (!sourceTable) continue;
143
-
144
- const sourceColumns = columnNames(sourceTable);
145
- const relations = resolveCollectionRelations(collection);
146
-
147
- for (const relation of Object.values(relations)) {
148
- const at = { collection: collection.slug,
149
- relationName: relation.relationName,
150
- kind: relation.kind };
151
-
152
- let targetCollection: CollectionConfig;
153
- try {
154
- targetCollection = relation.target();
155
- } catch (e) {
156
- defects.push({
157
- ...at,
158
- problem: `its \`target()\` threw: ${e instanceof Error ? e.message : String(e)}`,
159
- fix: "a target thunk usually throws because of a circular import — make sure it is `() => otherCollection` and not evaluated at module load"
160
- });
161
- continue;
162
- }
163
-
164
- // A target this registry has never heard of belongs to another
165
- // backend; its tables are not ours to check.
166
- if (!registeredSlugs.has(targetCollection.slug)) continue;
167
-
168
- const targetTableName = getTableName(targetCollection);
169
- const targetTable = registry.getTable(targetTableName);
170
- if (!targetTable) {
171
- defects.push({
172
- ...at,
173
- problem: `it points at collection \`${targetCollection.slug}\`, which has no table \`${targetTableName}\` in the schema`,
174
- fix: `create the \`${targetTableName}\` table, or correct \`table\` on the \`${targetCollection.slug}\` collection`
175
- });
176
- continue;
177
- }
178
- const targetColumns = columnNames(targetTable);
179
-
180
- switch (relation.kind) {
181
- case "belongsTo": {
182
- if (!sourceColumns.has(relation.localKey)) {
183
- // `localKey` defaults to the relation name run through
184
- // the foreign-key rule, so it moves with that rule.
185
- const stale = staleCodegenRename(
186
- relation.localKey,
187
- sourceColumns,
188
- [relation.relationName, targetCollection.slug]
189
- );
190
- defects.push(stale
191
- ? { ...at, ...staleCodegenDefect(sourceTableName, stale) }
192
- : {
193
- ...at,
194
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
195
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
196
- });
197
- }
198
- break;
199
- }
200
-
201
- case "hasOne":
202
- case "hasMany": {
203
- if (!targetColumns.has(relation.foreignKeyOnTarget)) {
204
- // The default is derived from *this* collection's slug —
205
- // the column on the target that points back here.
206
- const stale = staleCodegenRename(
207
- relation.foreignKeyOnTarget,
208
- targetColumns,
209
- [collection.slug]
210
- );
211
- defects.push(stale
212
- ? { ...at, ...staleCodegenDefect(targetTableName, stale) }
213
- : {
214
- ...at,
215
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
216
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
217
- });
218
- }
219
- // `sourceKey` is the easiest of the two to put on the wrong
220
- // side — it is the only column in a `hasMany` that lives
221
- // here rather than on the target, and naming a target column
222
- // reads perfectly well right next to `foreignKeyOnTarget`.
223
- if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) {
224
- defects.push({
225
- ...at,
226
- problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
227
- fix: targetColumns.has(relation.sourceKey)
228
- ? `it is a column on the *target* table \`${targetTableName}\` — \`sourceKey\` names ` +
229
- "the column on this collection that the target's foreign key points at, so it " +
230
- `must be one of: ${quote(sourceColumns)}`
231
- : `add the column, or set \`sourceKey\` to one of: ${quote(sourceColumns)}`
232
- });
233
- }
234
- break;
235
- }
236
-
237
- case "manyToMany": {
238
- const { table, sourceColumn, targetColumn } = relation.through;
239
- const junction = registry.getTable(table);
240
- if (!junction) {
241
- defects.push({
242
- ...at,
243
- problem: `its junction table \`${table}\` does not exist`,
244
- fix: `create \`${table}\`, or name the real one with \`through: { table: "..." }\`. ` +
245
- "Note that an omitted `through.table` is derived from the two table names sorted " +
246
- "and joined, so renaming a table changes it"
247
- });
248
- break;
249
- }
250
- const junctionColumns = columnNames(junction);
251
- // Junction columns are the ones that actually moved in 0.13:
252
- // each defaults to its endpoint collection's *slug* run
253
- // through the foreign-key rule, and slugs are plural.
254
- const derivedFrom = {
255
- sourceColumn: [collection.slug],
256
- targetColumn: [targetCollection.slug]
257
- } as const;
258
- for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
259
- if (!junctionColumns.has(column)) {
260
- const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
261
- defects.push(stale
262
- ? { ...at, ...staleCodegenDefect(table, stale) }
263
- : {
264
- ...at,
265
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
266
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
267
- (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
268
- });
269
- }
270
- }
271
- break;
272
- }
273
-
274
- case "via": {
275
- if (relation.joinPath.length === 0) {
276
- defects.push({
277
- ...at,
278
- problem: "its `joinPath` is empty, so it joins nothing",
279
- fix: "add at least one step, ending at the target's table"
280
- });
281
- break;
282
- }
283
-
284
- // Walk the chain: each step's `from` names columns on the
285
- // previous table, its `to` names columns on its own.
286
- let prevName = sourceTableName;
287
- let prevColumns = sourceColumns;
288
- let broken = false;
289
-
290
- for (const [i, step] of relation.joinPath.entries()) {
291
- const stepTable = registry.getTable(step.table);
292
- if (!stepTable) {
293
- defects.push({
294
- ...at,
295
- problem: `step ${i + 1} of its \`joinPath\` joins \`${step.table}\`, which is not a table in the schema`,
296
- fix: `correct \`joinPath[${i}].table\``
297
- });
298
- broken = true;
299
- break;
300
- }
301
- const stepColumns = columnNames(stepTable);
302
-
303
- for (const column of asColumns(step.on.from)) {
304
- if (!prevColumns.has(column)) {
305
- defects.push({
306
- ...at,
307
- problem: `step ${i + 1} joins \`${prevName}.${column}\` → \`${step.table}\`, but \`${column}\` is not a column on \`${prevName}\``,
308
- fix: `\`joinPath[${i}].on.from\` names columns on ${i === 0 ? "this collection's table" : `the previous step's table (\`${prevName}\`)`}: ${quote(prevColumns)}`
309
- });
310
- }
311
- }
312
- for (const column of asColumns(step.on.to)) {
313
- if (!stepColumns.has(column)) {
314
- defects.push({
315
- ...at,
316
- problem: `step ${i + 1} joins into \`${step.table}.${column}\`, but \`${column}\` is not a column on \`${step.table}\``,
317
- fix: `\`joinPath[${i}].on.to\` names columns on \`${step.table}\`: ${quote(stepColumns)}`
318
- });
319
- }
320
- }
321
-
322
- if (asColumns(step.on.from).length !== asColumns(step.on.to).length) {
323
- defects.push({
324
- ...at,
325
- problem: `step ${i + 1} compares ${asColumns(step.on.from).length} column(s) against ${asColumns(step.on.to).length}`,
326
- fix: `\`from\` and \`to\` must name the same number of columns in \`joinPath[${i}]\``
327
- });
328
- }
329
-
330
- prevName = step.table;
331
- prevColumns = stepColumns;
332
- }
333
-
334
- // The chain has to end where the relation says it points,
335
- // or the rows it returns are not the target's rows.
336
- if (!broken && prevName !== targetTableName) {
337
- defects.push({
338
- ...at,
339
- problem: `its \`joinPath\` ends at \`${prevName}\`, but it targets \`${targetCollection.slug}\` (table \`${targetTableName}\`)`,
340
- fix: `make the last step join \`${targetTableName}\`, or point \`target\` at the collection backed by \`${prevName}\``
341
- });
342
- }
343
- break;
344
- }
345
-
346
- default: {
347
- const exhaustive: never = relation;
348
- throw new Error(`Unhandled relation kind: ${JSON.stringify(exhaustive)}`);
349
- }
350
- }
351
- }
352
- }
353
-
354
- return defects;
355
- }
356
-
357
- /**
358
- * Fail boot on any relation that cannot resolve, listing all of them at once.
359
- *
360
- * Deliberately fatal rather than a warning. Every one of these produces an
361
- * empty result at query time and nothing else — an empty tab, an empty
362
- * `include`, a subcollection that looks like it has no rows. A server that
363
- * refuses to start is recoverable in a minute; a relation that quietly answers
364
- * "nothing" is the kind of bug found in production, weeks later, by a user
365
- * asking where their data went.
366
- */
367
- export function assertRelationsResolve(
368
- collections: CollectionConfig[],
369
- registry: PostgresCollectionRegistry
370
- ): void {
371
- const defects = findRelationDefects(collections, registry);
372
- if (defects.length === 0) return;
373
-
374
- const lines = defects.map(d =>
375
- ` • ${d.collection}.${d.relationName} (${d.kind})\n` +
376
- ` ${d.problem}\n` +
377
- ` fix: ${d.fix}`
378
- );
379
-
380
- throw new Error(
381
- `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against ` +
382
- "`backend/src/schema.generated.ts`.\n\n" +
383
- "Each of these would return no rows at query time rather than reporting an error, " +
384
- "so they are fatal at boot instead.\n\n" +
385
- // This reads the *generated file*, not the database, and the difference
386
- // is the whole diagnosis after an upgrade. Boot-ensure renames columns
387
- // in the database — a 0.12 → 0.13 upgrade singularises a junction key,
388
- // `categorie_id` → `category_id` — and the checked-in file still
389
- // declares the old name. The config is then correct and the file is
390
- // stale, so the per-defect advice below, which lists the columns this
391
- // file has, names a column that no longer exists in the database.
392
- // Following it turns a recoverable state into a broken config.
393
- //
394
- // Hence the ordering: regenerate first, and only then consider that the
395
- // collection might be the thing that is wrong.
396
- "If the database was migrated recently — an upgrade, a `db push`, a restore — this file is\n" +
397
- "probably older than the schema it describes. Regenerate it before changing anything else:\n\n" +
398
- " rebase schema generate\n\n" +
399
- "If it is already current, then the collection is what disagrees with it:\n\n" +
400
- lines.join("\n\n") + "\n"
401
- );
402
- }