@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g0d7af95

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 (99) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +75 -12
  3. package/dist/PostgresBootstrapper.d.ts +6 -6
  4. package/dist/auth/services.d.ts +1 -1
  5. package/dist/backup/backup-cron.d.ts +1 -1
  6. package/dist/backup/backup-service.d.ts +2 -2
  7. package/dist/backup/index.d.ts +4 -4
  8. package/dist/{backup-service-BZoixhVl.js → backup-service-BtgHxfFm.js} +5 -4
  9. package/dist/{backup-service-BZoixhVl.js.map → backup-service-BtgHxfFm.js.map} +1 -1
  10. package/dist/cli-helpers.d.ts +41 -0
  11. package/dist/{auth-users-columns-CgyPWQ18.js → collection-index-DxJBvVTH.js} +486 -503
  12. package/dist/collection-index-DxJBvVTH.js.map +1 -0
  13. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  14. package/dist/collections/buildRegistry.d.ts +1 -1
  15. package/dist/collections/validate-relations.d.ts +1 -1
  16. package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
  17. package/dist/connection-GOKU3Hu5.js.map +1 -0
  18. package/dist/connection.d.ts +16 -0
  19. package/dist/data-transformer.d.ts +1 -1
  20. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-DFpOl8SM.js} +4 -4
  21. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
  22. package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
  23. package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
  24. package/dist/index.d.ts +16 -16
  25. package/dist/index.es.js +19 -7128
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DNzaWd4C.js} +3 -3
  28. package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DNzaWd4C.js.map} +1 -1
  29. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CInuYj1-.js} +14 -4
  30. package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
  31. package/dist/schema/classify-change.d.ts +82 -0
  32. package/dist/schema/collection-index.d.ts +182 -0
  33. package/dist/schema/dynamic-tables.d.ts +1 -1
  34. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  35. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  36. package/dist/schema/generate-schema-commit.d.ts +136 -0
  37. package/dist/schema/generated-schema-staleness.d.ts +19 -0
  38. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  39. package/dist/schema/introspect-db-logic.d.ts +3 -3
  40. package/dist/schema/introspect-db-project.d.ts +1 -1
  41. package/dist/schema/introspect-db-queries.d.ts +1 -1
  42. package/dist/schema/introspect-db-structure.d.ts +2 -2
  43. package/dist/schema/introspect-runtime.d.ts +1 -1
  44. package/dist/schema/vector-index.d.ts +88 -0
  45. package/dist/services/BranchService.d.ts +2 -2
  46. package/dist/services/FetchService.d.ts +4 -4
  47. package/dist/services/PersistService.d.ts +5 -5
  48. package/dist/services/RelationService.d.ts +3 -3
  49. package/dist/services/RelationWriteService.d.ts +3 -3
  50. package/dist/services/cdc/junction-tables.d.ts +1 -1
  51. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  52. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  53. package/dist/services/channel-bus/index.d.ts +2 -2
  54. package/dist/services/collection-helpers.d.ts +1 -1
  55. package/dist/services/dataService.d.ts +10 -10
  56. package/dist/services/index.d.ts +4 -4
  57. package/dist/services/junction-writes.d.ts +2 -2
  58. package/dist/services/nested-path.d.ts +1 -1
  59. package/dist/services/realtimeService.d.ts +3 -3
  60. package/dist/services/row-pipeline.d.ts +1 -1
  61. package/dist/services/write-denial.d.ts +1 -1
  62. package/dist/{src-BBFsDaeA.js → src-DiDgtX8P.js} +97 -1
  63. package/dist/src-DiDgtX8P.js.map +1 -0
  64. package/dist/utils/drizzle-conditions.d.ts +2 -2
  65. package/dist/websocket-CUnpSe8v.js +8188 -0
  66. package/dist/websocket-CUnpSe8v.js.map +1 -0
  67. package/dist/websocket.d.ts +29 -2
  68. package/package.json +7 -7
  69. package/src/PostgresBackendDriver.ts +190 -59
  70. package/src/backup/backup-service.ts +1 -1
  71. package/src/cli-helpers.ts +117 -2
  72. package/src/cli.ts +22 -0
  73. package/src/connection.ts +37 -3
  74. package/src/databasePoolManager.ts +5 -2
  75. package/src/schema/classify-change.ts +436 -0
  76. package/src/schema/collection-index.ts +427 -0
  77. package/src/schema/ensure-collection-tables.test.ts +168 -1
  78. package/src/schema/ensure-collection-tables.ts +365 -14
  79. package/src/schema/generate-drizzle-schema-logic.ts +23 -11
  80. package/src/schema/generate-drizzle-schema.ts +13 -2
  81. package/src/schema/generate-postgres-ddl-logic.ts +33 -6
  82. package/src/schema/generate-postgres-ddl.ts +13 -2
  83. package/src/schema/generate-schema-commit.ts +242 -0
  84. package/src/schema/generated-schema-staleness.ts +114 -1
  85. package/src/schema/vector-index.ts +278 -0
  86. package/src/services/collection-helpers.ts +3 -2
  87. package/src/websocket.ts +47 -3
  88. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  89. package/dist/connection-BuZ97wsr.js.map +0 -1
  90. package/dist/data_driver-ULAyJEi9.js +0 -193
  91. package/dist/data_driver-ULAyJEi9.js.map +0 -1
  92. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  93. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
  94. package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
  95. package/dist/src-BBFsDaeA.js.map +0 -1
  96. package/dist/utils/table-classification.d.ts +0 -8
  97. package/dist/websocket-BVgDVO-V.js +0 -614
  98. package/dist/websocket-BVgDVO-V.js.map +0 -1
  99. package/src/utils/table-classification.ts +0 -16
@@ -15,6 +15,12 @@ import {
15
15
  searchIndexNames,
16
16
  type SearchColumnSpec
17
17
  } from "./search-column";
18
+ import {
19
+ buildVectorIndexPlan,
20
+ vectorIndexStatements,
21
+ type VectorIndexPlan
22
+ } from "./vector-index";
23
+ import { buildCollectionIndexSpecs, collectionIndexStatements } from "./collection-index";
18
24
  import { REBASE_SCHEMA } from "@rebasepro/types";
19
25
 
20
26
  // --- Helper Functions ---
@@ -719,7 +725,24 @@ export const generatePostgresDdl = async (
719
725
  indexStatements.push(...searchIndexStatements(searchSpec));
720
726
  }
721
727
 
722
- // Backwards compatibility: add default id primary key if missing
728
+ // ANN indexes for vector columns. Emitted with the other indexes
729
+ // rather than inline, because `CREATE INDEX` is a statement and a
730
+ // column definition is not — and because a column too wide for
731
+ // pgvector to index still needs its column.
732
+ const vectorPlan: VectorIndexPlan = buildVectorIndexPlan(collection, resolveColumnName);
733
+ indexStatements.push(...vectorIndexStatements(vectorPlan));
734
+ for (const skip of vectorPlan.skipped) {
735
+ indexStatements.push(`-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}`);
736
+ }
737
+
738
+ // The collection's own `indexes:` block. Last of the three index
739
+ // producers, and the only one the developer wrote deliberately
740
+ // rather than getting as a side effect of another feature.
741
+ indexStatements.push(...collectionIndexStatements(
742
+ buildCollectionIndexSpecs(collection, resolveColumnName)
743
+ ));
744
+
745
+ // The implicit primary key, for a collection that declares none.
723
746
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
724
747
  if (!hasPk) {
725
748
  columns.unshift(' "id" TEXT PRIMARY KEY');
@@ -747,16 +770,20 @@ export const generatePostgresDdl = async (
747
770
  }
748
771
  }
749
772
 
750
- if (fkStatements.length > 0) {
751
- ddl += "-- Foreign Key Constraints\n";
752
- ddl += fkStatements.join("\n") + "\n\n";
753
- }
754
-
773
+ // Indexes before foreign keys. Today every FK targets a primary key created
774
+ // inline, so the order is safe by accident; the moment a declared
775
+ // `unique: true` index can back an FK target, a constraint emitted first
776
+ // would reference an index that does not exist yet.
755
777
  if (indexStatements.length > 0) {
756
778
  ddl += "-- Indexes\n";
757
779
  ddl += indexStatements.join("\n") + "\n\n";
758
780
  }
759
781
 
782
+ if (fkStatements.length > 0) {
783
+ ddl += "-- Foreign Key Constraints\n";
784
+ ddl += fkStatements.join("\n") + "\n\n";
785
+ }
786
+
760
787
  if (policyStatements.length > 0) {
761
788
  ddl += "-- Row Level Security Policies\n";
762
789
  ddl += policyStatements.join("");
@@ -2,7 +2,6 @@ import { promises as fsPromises } from "fs";
2
2
  import * as fs from "fs";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
- import chokidar from "chokidar";
6
5
  import {
7
6
  generatePostgresDdl,
8
7
  generatePostgresPoliciesDdl,
@@ -77,7 +76,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
77
76
  }
78
77
  };
79
78
 
80
- const main = () => {
79
+ const main = async () => {
81
80
  const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
82
81
  const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
83
82
 
@@ -96,6 +95,18 @@ const main = () => {
96
95
 
97
96
  if (watch) {
98
97
  out(`Watching for changes in ${resolvedPath}...`);
98
+ // Imported here rather than at module scope, and this is not a style
99
+ // choice: chokidar is needed only by `--watch`, which is a
100
+ // schema-authoring path that never runs inside the runtime image. A
101
+ // top-level import puts it on the boot path of the published driver
102
+ // bundle, and the image installs a hand-listed set of runtime
103
+ // dependencies that does not include it — so the whole driver failed to
104
+ // load with "Cannot find package 'chokidar'", and every self-hosted
105
+ // container answered 500 with a stack trace about a file watcher.
106
+ //
107
+ // Same reasoning the image already applies to @ariga/atlas: an
108
+ // authoring-only dependency does not belong on a boot path.
109
+ const { default: chokidar } = await import("chokidar");
99
110
  const watcher = chokidar.watch(resolvedPath, {
100
111
  persistent: true,
101
112
  ignoreInitial: false
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Everything a schema change has to write, as file contents.
3
+ *
4
+ * A live schema editor that only edits the collection source produces a repo
5
+ * that does not build: `backend/src/schema.generated.ts` is a committed
6
+ * artifact, and a stale one has broken every deploy at least once. So the unit
7
+ * of a schema change is not a file, it is a **commit** — and this module
8
+ * produces one, without touching a disk, a database or a network.
9
+ *
10
+ * Pure on purpose. The risky half of "commit, then apply" is generating a
11
+ * correct commit; keeping it a function from collections to file contents is
12
+ * what lets that half be tested by building a database from the result and
13
+ * comparing it to the one the change describes.
14
+ *
15
+ * ## Where the migration comes from, and why not from Atlas
16
+ *
17
+ * `rebase db generate` mints migrations by running Atlas over the generated
18
+ * `schema.sql`. Atlas is an external binary, it wants a dev database, and it
19
+ * maintains an `atlas.sum` integrity file whose hash this module would have to
20
+ * reproduce byte-for-byte to stay valid.
21
+ *
22
+ * None of that is necessary here, because of what the editor is allowed to do.
23
+ * `classify-change.ts` refuses anything the boot-time ensure path cannot
24
+ * express, which leaves only additive statements — and those are computable as
25
+ * a plain difference between two ensure plans:
26
+ *
27
+ * plan(after, nothing) − plan(before, nothing)
28
+ *
29
+ * Both plans are pure functions of the collections, every statement is
30
+ * idempotent, and the difference is exactly what the change adds. No diff
31
+ * engine, no database, no binary.
32
+ *
33
+ * The statements are *returned* rather than written into a migration file. A
34
+ * project provisioned by boot-ensure needs no migration at all — its
35
+ * collections are the schema — while a project provisioned by migrations needs
36
+ * the file to carry an Atlas hash, which only Atlas can mint. Writing a
37
+ * migration this module cannot make valid would be worse than handing the
38
+ * statements to a caller who knows which kind of project it is.
39
+ */
40
+ import { DEFAULT_COMMIT_PATHS, type CollectionConfig, type SchemaCommitPaths } from "@rebasepro/types";
41
+ import {
42
+ generatePostgresDdl,
43
+ generatePostgresPoliciesDdl,
44
+ generatePostgresSearchDdl
45
+ } from "./generate-postgres-ddl-logic";
46
+ import { generateSchema } from "./generate-drizzle-schema-logic";
47
+ import {
48
+ planCollectionSchemaEnsure,
49
+ type EnsureOptions,
50
+ type ExistingSchema,
51
+ type WithheldConstraint
52
+ } from "./ensure-collection-tables";
53
+ import { classifyCollectionChanges, type ClassifiedChanges } from "./classify-change";
54
+
55
+ /**
56
+ * Re-exported from the shared kernel. `@rebasepro/server` derives these for a
57
+ * project in a subdirectory and cannot import a driver to do it, so the shape
58
+ * and the defaults live in `@rebasepro/types`.
59
+ */
60
+ export { DEFAULT_COMMIT_PATHS, type SchemaCommitPaths } from "@rebasepro/types";
61
+
62
+ export interface SchemaCommitFile {
63
+ path: string;
64
+ contents: string;
65
+ }
66
+
67
+ export interface SchemaCommitInput {
68
+ /** What the running database was built from. */
69
+ before: CollectionConfig[];
70
+ /** What the editor is proposing. */
71
+ after: CollectionConfig[];
72
+ /**
73
+ * Files the caller has already produced — in practice the rewritten
74
+ * collection source from the AST editor. Carried through unchanged so the
75
+ * commit is complete in one object.
76
+ */
77
+ sourceFiles?: SchemaCommitFile[];
78
+ paths?: Partial<SchemaCommitPaths>;
79
+ /**
80
+ * What the database this change is destined for actually has.
81
+ *
82
+ * Supplied by the live editor, which read it a moment ago; omitted by the
83
+ * pure callers, which have no database. It decides two things a plan cannot
84
+ * know from the collections alone — whether a table holds rows, and which
85
+ * values an enum type already carries — and both are the difference between
86
+ * a statement that applies and one that is rejected.
87
+ */
88
+ existing?: ExistingSchema;
89
+ }
90
+
91
+ export interface SchemaCommit {
92
+ /** Every file the commit writes, source and generated alike. */
93
+ files: SchemaCommitFile[];
94
+ /**
95
+ * The additive statements this change adds, in dependency order.
96
+ *
97
+ * Empty when the change needs no DDL. Not written to a migration file —
98
+ * see the module comment.
99
+ */
100
+ statements: string[];
101
+ classified: ClassifiedChanges;
102
+ /** A commit message describing the change in the terms a reader wants. */
103
+ message: string;
104
+ /**
105
+ * Constraints this change asks for that the statements do not carry, and
106
+ * why. Empty for almost every change; when it is not, it is the thing the
107
+ * person confirming needs to read before they confirm.
108
+ */
109
+ withheldConstraints: WithheldConstraint[];
110
+ }
111
+
112
+ export class SchemaCommitError extends Error {
113
+ constructor(message: string, readonly classified: ClassifiedChanges) {
114
+ super(message);
115
+ this.name = "SchemaCommitError";
116
+ }
117
+ }
118
+
119
+ /** An `ExistingSchema` describing a database that has nothing in it. */
120
+ const nothing = () => ({ tables: new Map<string, Set<string>>(), enums: new Set<string>() });
121
+
122
+ /**
123
+ * The statements that take `before` to `after`.
124
+ *
125
+ * Both sides are planned against the *same* database and the difference is
126
+ * taken by exact statement text. That works because the planner is
127
+ * deterministic: the same collections against the same schema produce the same
128
+ * strings, so anything in the second plan and absent from the first is what
129
+ * this change adds — and nothing else. Planning both sides is what keeps
130
+ * pre-existing drift, which belongs to neither side of the edit, out of the
131
+ * statements this change gets credited with.
132
+ *
133
+ * ## Why `existing` matters more than it looks
134
+ *
135
+ * Planned against `nothing()`, every table reads as one this plan is creating,
136
+ * and the planner is then free to attach constraints that only hold on a table
137
+ * with no rows: a new required property comes out as
138
+ * `ADD COLUMN "x" TEXT NOT NULL`, which is right for a fresh table and fails
139
+ * against a live one holding rows. Those statements would be generated,
140
+ * committed, and then rejected by the very database they were written for.
141
+ *
142
+ * So a caller holding a real database passes it, and gets statements that
143
+ * describe that database. `nothing()` stays the default for the pure uses —
144
+ * generating a commit for inspection, and the tests that compare two plans —
145
+ * where there is no database to describe.
146
+ */
147
+ export function additiveStatements(
148
+ before: CollectionConfig[],
149
+ after: CollectionConfig[],
150
+ existing: ExistingSchema = nothing(),
151
+ options: EnsureOptions = {}
152
+ ): string[] {
153
+ const previous = new Set(planCollectionSchemaEnsure(before, existing, options).statements);
154
+ return planCollectionSchemaEnsure(after, existing, options).statements
155
+ .filter(statement => !previous.has(statement));
156
+ }
157
+
158
+ /** A commit message that says what changed rather than that something did. */
159
+ export function commitMessage(classified: ClassifiedChanges): string {
160
+ const { changes } = classified;
161
+ if (changes.length === 0) return "chore(schema): no change";
162
+
163
+ const collections = [...new Set(changes.map(change => change.collection))].sort();
164
+ const added = changes.filter(c => c.kind === "add-collection").map(c => c.collection);
165
+ const properties = changes.filter(c => c.kind === "add-property");
166
+
167
+ let subject: string;
168
+ if (added.length === 1 && changes.length === 1) {
169
+ subject = `add the ${added[0]} collection`;
170
+ } else if (properties.length === 1 && changes.length === 1) {
171
+ subject = `add ${properties[0].property} to ${properties[0].collection}`;
172
+ } else if (collections.length === 1) {
173
+ subject = `${changes.length} change(s) to ${collections[0]}`;
174
+ } else {
175
+ subject = `${changes.length} change(s) across ${collections.length} collections`;
176
+ }
177
+
178
+ const body = changes.map(change => `- ${change.detail}`).join("\n");
179
+ return `feat(schema): ${subject}\n\n${body}\n`;
180
+ }
181
+
182
+ /**
183
+ * Build the commit.
184
+ *
185
+ * Refuses when the change is not applicable — a commit describing a schema the
186
+ * ensure path will not produce is a commit that makes the repository lie about
187
+ * the database. The classification travels on the error so a caller can show
188
+ * exactly which change was the problem.
189
+ */
190
+ export async function generateSchemaCommit(input: SchemaCommitInput): Promise<SchemaCommit> {
191
+ const paths = { ...DEFAULT_COMMIT_PATHS, ...input.paths };
192
+ const existing = input.existing ?? nothing();
193
+ // `converge` because every statement this produces is shown to somebody
194
+ // before it runs. See `ConstraintPolicy` for why the unattended boot does
195
+ // not get the same latitude.
196
+ const options: EnsureOptions = { constraints: "converge" };
197
+ const classified = classifyCollectionChanges(input.before, input.after, input.existing);
198
+
199
+ if (!classified.applicable) {
200
+ const blocking = classified.changes.filter(change => change.verdict !== "safe");
201
+ throw new SchemaCommitError(
202
+ `This change cannot be applied to a running database:\n` +
203
+ blocking.map(change =>
204
+ ` • ${change.detail}${change.remedy ? `\n ${change.remedy}` : ""}`
205
+ ).join("\n"),
206
+ classified
207
+ );
208
+ }
209
+
210
+ const [schema, ddl, policies, search] = await Promise.all([
211
+ generateSchema(input.after),
212
+ generatePostgresDdl(input.after),
213
+ Promise.resolve(generatePostgresPoliciesDdl(input.after)),
214
+ Promise.resolve(generatePostgresSearchDdl(input.after))
215
+ ]);
216
+
217
+ const generated: SchemaCommitFile[] = [
218
+ { path: paths.schemaFile, contents: schema },
219
+ { path: paths.ddlFile, contents: ddl },
220
+ { path: paths.policiesFile, contents: policies },
221
+ { path: paths.searchFile, contents: search }
222
+ ];
223
+
224
+ // Both sides planned once, here, rather than through `additiveStatements` —
225
+ // which would plan `after` a second time for the withheld constraints. The
226
+ // planner is pure, so a second call is only wasted work rather than a
227
+ // correctness problem, but this runs on every `/plan` keystroke in the panel.
228
+ const previous = planCollectionSchemaEnsure(input.before, existing, options);
229
+ const next = planCollectionSchemaEnsure(input.after, existing, options);
230
+ const already = new Set(previous.statements);
231
+
232
+ return {
233
+ files: [...(input.sourceFiles ?? []), ...generated],
234
+ statements: next.statements.filter(statement => !already.has(statement)),
235
+ classified,
236
+ message: commitMessage(classified),
237
+ // From the `after` plan alone rather than differenced against `before`:
238
+ // a constraint that was already unenforceable is still something the
239
+ // person confirming this change should see named.
240
+ withheldConstraints: next.withheldConstraints
241
+ };
242
+ }
@@ -44,7 +44,14 @@ export interface LegacyForeignKeyName {
44
44
  * which is not staleness — it is a table the generator has not been asked about.
45
45
  */
46
46
  function tableBlock(source: string, table: string): string {
47
- const start = source.indexOf(`pgTable("${table}"`);
47
+ // Both forms the generator emits. A collection with `schema: "rebase"` — the
48
+ // auth users collection, in every scaffold — is written as
49
+ // `rebaseSchema.table("users", …)`, so matching only `pgTable(` made this
50
+ // blind to every table outside `public`: the block came back empty, and an
51
+ // empty block reads as "a table the generator has not been asked about".
52
+ // The rename check therefore never inspected them.
53
+ const literal = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ const start = source.search(new RegExp(`(?:pgTable|\\.table)\\(\\s*["']${literal}["']`));
48
55
  if (start === -1) return "";
49
56
  // Generated files put every table in its own `export const`, so the next one
50
57
  // is the end of this block. No brace counting, nothing to get wrong.
@@ -169,3 +176,109 @@ export function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): st
169
176
  .map(f => ` • ${f.table}.${f.legacy} → ${f.current} (${f.relation})`)
170
177
  .join("\n");
171
178
  }
179
+
180
+ // ── The other way a generated schema goes stale ──────────────────────────────
181
+ //
182
+ // The rename above is the subtle one: nothing the developer owns changed. The
183
+ // ordinary one is the opposite — a collection or a relation was added and the
184
+ // generator was not re-run, so the checked-in module simply does not describe
185
+ // part of the schema. It fails later and elsewhere: relation validation, or a
186
+ // query against a table the module has never heard of.
187
+ //
188
+ // Still not "is this byte-for-byte what the generator would emit now". This asks
189
+ // only about *names*, which is what the rest of the system reads out of the
190
+ // file, so reformatting the generator cannot make it red.
191
+
192
+ /** Something the collections derive that the generated schema does not declare. */
193
+ export interface MissingGeneratedName {
194
+ /** The table it belongs to, or is. */
195
+ table: string;
196
+ /** The column, when a column is what is missing. */
197
+ column?: string;
198
+ /** `<collection>` or `<collection>.<relation>`, for the message. */
199
+ source: string;
200
+ }
201
+
202
+ /**
203
+ * Tables and derived foreign-key columns the collections name and the generated
204
+ * schema does not.
205
+ *
206
+ * @param generatedSource contents of `backend/src/schema.generated.ts`
207
+ * @param collections the project's collections, as this release reads them
208
+ */
209
+ export function findMissingGeneratedNames(
210
+ generatedSource: string,
211
+ collections: CollectionConfig[]
212
+ ): MissingGeneratedName[] {
213
+ const missing: MissingGeneratedName[] = [];
214
+ const seen = new Set<string>();
215
+
216
+ const report = (table: string, source: string, column?: string): void => {
217
+ const key = `${table}.${column ?? ""}`;
218
+ if (seen.has(key)) return;
219
+ seen.add(key);
220
+ missing.push({ table, source, ...(column ? { column } : {}) });
221
+ };
222
+
223
+ const requireColumn = (table: string, column: string | undefined, source: string): void => {
224
+ if (!table || !column) return;
225
+ const block = tableBlock(generatedSource, table);
226
+ // A table that is absent is reported once, as a table. Listing each of
227
+ // its columns as separately missing would bury the one fact that
228
+ // explains all of them.
229
+ if (!block) return report(table, source);
230
+ if (!declaresColumn(block, column)) report(table, source, column);
231
+ };
232
+
233
+ for (const collection of relationalCollections(collections)) {
234
+ const sourceTable = getTableName(collection);
235
+ if (!tableBlock(generatedSource, sourceTable)) {
236
+ report(sourceTable, collection.slug);
237
+ continue;
238
+ }
239
+
240
+ for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
241
+ const at = `${collection.slug}.${name}`;
242
+
243
+ let target: CollectionConfig | undefined;
244
+ try {
245
+ target = relation.target?.();
246
+ } catch {
247
+ // Its own defect, reported at boot by `validate-relations`.
248
+ continue;
249
+ }
250
+
251
+ switch (relation.kind) {
252
+ case "belongsTo":
253
+ requireColumn(sourceTable, relation.localKey, at);
254
+ break;
255
+
256
+ case "hasOne":
257
+ case "hasMany":
258
+ if (target) requireColumn(getTableName(target), relation.foreignKeyOnTarget, at);
259
+ break;
260
+
261
+ case "manyToMany":
262
+ requireColumn(relation.through.table, relation.through.sourceColumn, at);
263
+ requireColumn(relation.through.table, relation.through.targetColumn, at);
264
+ break;
265
+
266
+ default:
267
+ // `via` joins are written by hand; there is no derived name
268
+ // to be missing.
269
+ break;
270
+ }
271
+ }
272
+ }
273
+
274
+ return missing;
275
+ }
276
+
277
+ /** One-line summary for a log or a CLI notice. */
278
+ export function describeMissingGeneratedNames(missing: MissingGeneratedName[]): string {
279
+ return missing
280
+ .map(m => (m.column
281
+ ? ` • ${m.table}.${m.column} is not declared (${m.source})`
282
+ : ` • table ${m.table} is not declared (${m.source})`))
283
+ .join("\n");
284
+ }