@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 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,295 +0,0 @@
1
- import { and, eq, inArray, sql } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { ApiError, logger } from "@rebasepro/server";
4
- import type { ResolvedVia } from "@rebasepro/types";
5
- import { DrizzleClient } from "../interfaces";
6
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
- import { relationMisconfigured } from "./collection-helpers";
8
- import { explainZeroRowWrite } from "./write-denial";
9
-
10
- /**
11
- * Writing a many-to-many means writing rows in a junction table, and doing that
12
- * needs three things: the table, the column naming the row being written from,
13
- * and the column naming the far side.
14
- *
15
- * Those three used to be re-derived at each of the four call sites — twice for
16
- * `through`, twice for `joinPath`, in the owning and the inverse direction —
17
- * and the two `joinPath` derivations did not agree. The inverse one keyed off
18
- * `step.table`; the owning one asked `getTableNamesFromColumns` which table a
19
- * step's columns belonged to, and that answers `""` for an unqualified column
20
- * name. So for `{ table: "posts_tags", on: { from: "id", to: "tag_id" } }` —
21
- * the form the docs and every fixture use — no branch matched, both columns
22
- * stayed null, and the write was skipped with a warning nobody reads. Writing
23
- * a to-many `via` relation did nothing at all.
24
- *
25
- * Hence one binder, keyed on `step.table` (always present) and accepting the
26
- * qualified `table.column` form where it is used.
27
- */
28
- export interface JunctionBinding {
29
- table: PgTable;
30
- /** The junction column holding the id of the row being written from. */
31
- parentColumn: AnyPgColumn;
32
- /** The junction column holding the id of the row on the far side. */
33
- targetColumn: AnyPgColumn;
34
- /** `<collection>.<relation>`, for error messages. */
35
- label: string;
36
- }
37
-
38
- /** A junction that cannot be resolved is a broken relation, not a no-op. */
39
- const misconfigured = (label: string, detail: string): ApiError =>
40
- relationMisconfigured(label, detail);
41
-
42
- function column(table: PgTable, name: string | undefined, label: string, role: string): AnyPgColumn {
43
- const col = name ? table[name as keyof typeof table] as AnyPgColumn : undefined;
44
- if (!col) {
45
- throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
46
- }
47
- return col;
48
- }
49
-
50
- /** The junction a `manyToMany` names outright. */
51
- export function bindThroughJunction(
52
- registry: PostgresCollectionRegistry,
53
- through: { table: string; sourceColumn: string; targetColumn: string },
54
- label: string
55
- ): JunctionBinding {
56
- const table = registry.getTable(through.table);
57
- if (!table) {
58
- throw misconfigured(label, `no table '${through.table}' in the registry`);
59
- }
60
- return {
61
- table,
62
- parentColumn: column(table, through.sourceColumn, label, "source"),
63
- targetColumn: column(table, through.targetColumn, label, "target"),
64
- label
65
- };
66
- }
67
-
68
- /** The bare column name, whether written as `col` or `table.col`. */
69
- const columnPart = (spec: string | string[]): string => {
70
- const one = Array.isArray(spec) ? spec[0] : spec;
71
- return one.includes(".") ? one.split(".")[1] : one;
72
- };
73
-
74
- /** The table a column spec names, or undefined when it is unqualified. */
75
- const tablePart = (spec: string | string[]): string | undefined => {
76
- const one = Array.isArray(spec) ? spec[0] : spec;
77
- return one.includes(".") ? one.split(".")[0] : undefined;
78
- };
79
-
80
- /**
81
- * The junction a `via` relation reaches through, from either end.
82
- *
83
- * A step is `{ table: T, on: { from, to } }` where `from` names a column on
84
- * whatever the walk was standing on and `to` names one on `T`. That positional
85
- * meaning is what makes the unqualified form work at all, so the walk carries
86
- * the previous table rather than asking the column names where they live.
87
- */
88
- export function bindJoinPathJunction(
89
- registry: PostgresCollectionRegistry,
90
- joinPath: ResolvedVia["joinPath"],
91
- parentTableName: string,
92
- targetTableName: string,
93
- label: string
94
- ): JunctionBinding {
95
- const junctionName = joinPath
96
- .map(step => step.table)
97
- .find(table => table !== parentTableName && table !== targetTableName);
98
-
99
- if (!junctionName) {
100
- throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
101
- }
102
-
103
- const table = registry.getTable(junctionName);
104
- if (!table) {
105
- throw misconfigured(label, `no table '${junctionName}' in the registry`);
106
- }
107
-
108
- let parentColumnName: string | undefined;
109
- let targetColumnName: string | undefined;
110
- let previousTable = parentTableName;
111
-
112
- for (const step of joinPath) {
113
- const fromTable = tablePart(step.on.from) ?? previousTable;
114
- const toTable = tablePart(step.on.to) ?? step.table;
115
-
116
- // Take the column on the junction's own side of the step, and let the
117
- // other side say which end of the relation this step reached.
118
- if (toTable === junctionName && fromTable === parentTableName) {
119
- parentColumnName = columnPart(step.on.to);
120
- } else if (fromTable === junctionName && toTable === parentTableName) {
121
- parentColumnName = columnPart(step.on.from);
122
- } else if (toTable === junctionName && fromTable === targetTableName) {
123
- targetColumnName = columnPart(step.on.to);
124
- } else if (fromTable === junctionName && toTable === targetTableName) {
125
- targetColumnName = columnPart(step.on.from);
126
- }
127
-
128
- previousTable = step.table;
129
- }
130
-
131
- if (!parentColumnName || !targetColumnName) {
132
- throw misconfigured(
133
- label,
134
- `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`
135
- );
136
- }
137
-
138
- return {
139
- table,
140
- parentColumn: column(table, parentColumnName, label, "source"),
141
- targetColumn: column(table, targetColumnName, label, "target"),
142
- label
143
- };
144
- }
145
-
146
- /**
147
- * Remove one link, leaving the row on the far side alone.
148
- *
149
- * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
150
- * target is shared, so deleting the row would remove the tag from every other
151
- * post that uses it.
152
- */
153
- export async function removeJunctionLink(
154
- tx: DrizzleClient,
155
- binding: JunctionBinding,
156
- parentId: unknown,
157
- targetId: unknown,
158
- subject: { parent: string; relation: string }
159
- ): Promise<void> {
160
- const conditions = [
161
- eq(binding.parentColumn, parentId),
162
- eq(binding.targetColumn, targetId)
163
- ];
164
- const result = await tx.delete(binding.table).where(and(...conditions));
165
-
166
- // The link is the row here, so the junction's own policies decide. Callers
167
- // establish membership first, over this same handle, which is what makes a
168
- // zero-row delete meaningful rather than ambiguous.
169
- if ((result.rowCount ?? 0) === 0) {
170
- throw await explainZeroRowWrite(
171
- tx,
172
- binding.table,
173
- conditions,
174
- `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": ` +
175
- "a row-level security policy rejected the write.",
176
- `No "${subject.relation}" link between "${subject.parent}" "${parentId}" ` +
177
- `and "${targetId}" to remove.`
178
- );
179
- }
180
-
181
- logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
182
- }
183
-
184
- /**
185
- * Make the junction say that `parentId` is linked to exactly `targetIds`, by
186
- * diffing against what is linked now rather than replacing the set.
187
- *
188
- * A save of the parent used to delete every junction row for it and re-insert
189
- * the ids the browser sent — a list the browser assembled from a read it did
190
- * earlier. Three things followed, all data loss rather than display:
191
- *
192
- * - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
193
- * saves any field from a form that predates it, and X is gone with nothing
194
- * reported to either of them.
195
- * - **A partially-read set is a partially-deleted set.** The read that fills
196
- * the form runs under RLS, so a user who may edit the parent but cannot see
197
- * some of the linked rows gets a shorter list — and writing it back deleted
198
- * the links they were never shown. The select that drives the diff runs in
199
- * this same transaction under the same policies, so a link the caller cannot
200
- * read is in neither list and survives the save.
201
- * - **Junction payload columns.** A junction carrying its own columns
202
- * (`position`, `role`, `created_at`) lost them on every save, because every
203
- * row was re-inserted with only the two keys. Untouched links are left alone.
204
- *
205
- * The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
206
- * concurrently is a no-op rather than a unique violation.
207
- */
208
- export async function applyJunctionMembership(
209
- tx: DrizzleClient,
210
- binding: JunctionBinding,
211
- parentId: unknown,
212
- targetIds: unknown[]
213
- ): Promise<void> {
214
- const existingRows = await tx
215
- .select({ targetId: binding.targetColumn })
216
- .from(binding.table)
217
- .where(eq(binding.parentColumn, parentId));
218
-
219
- // Keyed by `String(...)` because a junction key can come back from the
220
- // driver as a string where the parsed value is a number, and a diff that
221
- // missed that would delete and re-insert every link on every save.
222
- const existingById = new Map<string, unknown>();
223
- for (const row of existingRows as Array<{ targetId: unknown }>) {
224
- if (row.targetId === null || row.targetId === undefined) continue;
225
- existingById.set(String(row.targetId), row.targetId);
226
- }
227
-
228
- const wantedById = new Map<string, unknown>();
229
- for (const targetId of targetIds) {
230
- if (targetId === null || targetId === undefined) continue;
231
- wantedById.set(String(targetId), targetId);
232
- }
233
-
234
- const removed = [...existingById.entries()]
235
- .filter(([key]) => !wantedById.has(key))
236
- .map(([, value]) => value);
237
- const added = [...wantedById.entries()]
238
- .filter(([key]) => !existingById.has(key))
239
- .map(([, value]) => value);
240
-
241
- if (removed.length > 0) {
242
- await removeLinks(tx, binding, parentId, removed);
243
- }
244
-
245
- if (added.length > 0) {
246
- await tx.insert(binding.table)
247
- .values(added.map(targetId => ({
248
- [binding.parentColumn.name]: parentId,
249
- [binding.targetColumn.name]: targetId
250
- })))
251
- .onConflictDoNothing();
252
- }
253
- }
254
-
255
- /**
256
- * Drop the named links, and refuse to call it done if the database kept any.
257
- *
258
- * Every id here came out of the select in {@link applyJunctionMembership}, on
259
- * this same handle, so all of them were visible. Fewer deletions than that means
260
- * something refused them — and a save that reports success while the membership
261
- * it stored is not the membership it was given has told the caller something
262
- * untrue about the database.
263
- */
264
- async function removeLinks(
265
- tx: DrizzleClient,
266
- binding: JunctionBinding,
267
- parentId: unknown,
268
- removed: unknown[]
269
- ): Promise<void> {
270
- const conditions = [
271
- eq(binding.parentColumn, parentId),
272
- inArray(binding.targetColumn, removed)
273
- ];
274
- const result = await tx.delete(binding.table).where(and(...conditions));
275
- const deleted = result.rowCount ?? 0;
276
-
277
- if (deleted >= removed.length) return;
278
-
279
- const survivors = await tx
280
- .select({ present: sql<number>`1` })
281
- .from(binding.table)
282
- .where(and(...conditions))
283
- .limit(1);
284
-
285
- // Nothing survived: a concurrent session removed the rest between the
286
- // select and the delete. The membership is what the caller asked for,
287
- // which is all this promised.
288
- if (survivors.length === 0) return;
289
-
290
- throw ApiError.forbidden(
291
- `Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) ` +
292
- `for relation '${binding.label}': a row-level security policy rejected the write.`,
293
- "WRITE_DENIED"
294
- );
295
- }
@@ -1,145 +0,0 @@
1
- import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
2
- import { findRelation, resolveCollectionRelations } from "@rebasepro/common";
3
- import { ApiError } from "@rebasepro/server";
4
-
5
- import { getCollectionByPath } from "./collection-helpers";
6
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
-
8
- /**
9
- * The last hop of a nested collection path, e.g. `authors/1/posts`.
10
- *
11
- * The walk that produces this was written out four separate times — in
12
- * `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
13
- * `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
14
- * drifted, so the read path and the write path did not agree on which relation
15
- * a path named. It lives here once now.
16
- */
17
- export interface NestedPathHop {
18
- /** The collection the final relation is declared on (e.g. `authors`). */
19
- parentCollection: CollectionConfig;
20
- /** The parent's id as it appeared in the path, unparsed. */
21
- parentId: string;
22
- /** The path segment that named the relation (e.g. `posts`). */
23
- relationKey: string;
24
- relation: ResolvedRelation;
25
- /** `relation.target()`, resolved once. */
26
- targetCollection: CollectionConfig;
27
- }
28
-
29
- /**
30
- * True when `path` addresses rows through a relation rather than a root
31
- * collection.
32
- *
33
- * Any separator at all counts — a root collection slug never contains one — so
34
- * a malformed path like `collection/id` is a *broken* nested path and gets
35
- * reported as one by {@link resolveNestedPath}, rather than being looked up as
36
- * a root collection whose slug happens to contain a slash.
37
- */
38
- export function isNestedPath(path: string): boolean {
39
- return path.includes("/");
40
- }
41
-
42
- export function splitPathSegments(path: string): string[] {
43
- return path.split("/").filter(s => s && s !== "undefined");
44
- }
45
-
46
- /**
47
- * Walk a nested collection path down to the relation it ends in.
48
- *
49
- * Returns `undefined` for a plain root-collection path so callers can keep the
50
- * root case on its existing code path. Throws when the path is malformed, or
51
- * when a segment names a relation that does not exist — the same errors the
52
- * individual walks used to raise, with the available names attached.
53
- */
54
- export function resolveNestedPath(
55
- path: string,
56
- registry: PostgresCollectionRegistry
57
- ): NestedPathHop | undefined {
58
- if (!isNestedPath(path)) return undefined;
59
-
60
- const segments = splitPathSegments(path);
61
- if (segments.length < 3 || segments.length % 2 === 0) {
62
- throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
63
- }
64
-
65
- let parentCollection = getCollectionByPath(segments[0], registry);
66
- let parentId = segments[1];
67
-
68
- for (let i = 2; i < segments.length; i += 2) {
69
- const relationKey = segments[i];
70
- const resolvedRelations = resolveCollectionRelations(parentCollection);
71
- const relation = findRelation(resolvedRelations, relationKey);
72
-
73
- if (!relation) {
74
- const available = Object.keys(resolvedRelations).join(", ") || "(none)";
75
- throw new Error(
76
- `Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`
77
- );
78
- }
79
-
80
- const targetCollection = relation.target();
81
-
82
- if (i === segments.length - 1) {
83
- return {
84
- parentCollection,
85
- parentId,
86
- relationKey,
87
- relation,
88
- targetCollection
89
- };
90
- }
91
-
92
- parentCollection = targetCollection;
93
- parentId = segments[i + 1];
94
- }
95
-
96
- // Unreachable: the loop returns on the final segment, and the odd-length
97
- // check above guarantees there is one.
98
- throw new Error(`Unable to resolve path: ${path}`);
99
- }
100
-
101
- /**
102
- * A relation reached through a junction table — many-to-many, or a multi-hop
103
- * `joinPath`. The target row is shared with other parents, so writing "through"
104
- * such a path addresses the *link*, not the row.
105
- */
106
- export function isJunctionBackedRelation(relation: ResolvedRelation): boolean {
107
- return relation.shared;
108
- }
109
-
110
- /**
111
- * Reject a nested write whose final segment is a to-one relation.
112
- *
113
- * There is no column on the target row that records a to-one parent — the
114
- * foreign key lives on the *parent* table. The write path used to fall through
115
- * to `relation.localKey` here and stamp the parent's own FK column onto the
116
- * target row, which either raised an opaque "column does not exist" or, when a
117
- * column of that name happened to exist on the target, silently wrote the wrong
118
- * one.
119
- */
120
- export function assertWritableThrough(hop: NestedPathHop, path: string): void {
121
- const { relation } = hop;
122
-
123
- // Read the flag rather than re-deriving the rule. This guard used to test
124
- // `cardinality !== "many"`, which let a to-many `via` through even though
125
- // its type declares `writable: false` — a second expression of one rule,
126
- // free to disagree with the first, which is the shape of defect this union
127
- // exists to remove.
128
- if (relation.writable && relation.cardinality === "many") return;
129
-
130
- if (!relation.writable) {
131
- throw ApiError.badRequest(
132
- `"${path}" ends in '${hop.relationKey}', a \`via\` relation. Rebase will not infer how to write ` +
133
- `through an arbitrary join chain — write the row at "${hop.targetCollection.slug}" directly.`,
134
- "RELATION_NOT_WRITABLE"
135
- );
136
- }
137
-
138
- throw ApiError.badRequest(
139
- `"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: ` +
140
- `the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on ` +
141
- `'${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set ` +
142
- `'${hop.relationKey}' on the parent instead.`,
143
- "RELATION_NOT_WRITABLE"
144
- );
145
- }
@@ -1,151 +0,0 @@
1
- /**
2
- * A dedicated, self-healing Postgres `LISTEN` connection.
3
- *
4
- * Every cross-instance feature in the backend needs the same thing: one
5
- * connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
6
- * comes back on its own after the database or the network drops it. CDC needed
7
- * it first; the channel bus needs it too. This is that connection, with the one
8
- * behaviour that matters to callers preserved: the **first** connect is
9
- * validated and rethrown, so a caller can fall back to a different strategy,
10
- * while every later drop is repaired quietly in the background.
11
- *
12
- * `LISTEN` is session state, so this connection must not go through a
13
- * transaction-mode pooler (PgBouncer): give it the direct database URL.
14
- */
15
-
16
- import { Client as PgClient } from "pg";
17
- import { logger } from "@rebasepro/server";
18
-
19
- export interface PgNotifyListenerOptions {
20
- /** Direct Postgres connection string (must bypass a transaction-mode pooler). */
21
- connectionString: string;
22
- /** NOTIFY channel to LISTEN on. Must be a plain identifier — it is interpolated. */
23
- channel: string;
24
- /** Called for every notification payload received. */
25
- onPayload: (payload: string) => void | Promise<void>;
26
- /** Prefix for log lines, e.g. `"[CDC]"`. */
27
- logLabel: string;
28
- /** Delay before a reconnect attempt. */
29
- reconnectDelayMs?: number;
30
- }
31
-
32
- const DEFAULT_RECONNECT_DELAY_MS = 3000;
33
- /** Guards the identifier interpolated into `LISTEN`. */
34
- const SAFE_CHANNEL = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
-
36
- export class PgNotifyListener {
37
- private client?: PgClient;
38
- private running = false;
39
- private reconnectTimer?: ReturnType<typeof setTimeout>;
40
-
41
- constructor(private readonly options: PgNotifyListenerOptions) {
42
- if (!SAFE_CHANNEL.test(options.channel)) {
43
- throw new Error(`Unsafe NOTIFY channel name "${options.channel}" — expected a plain SQL identifier.`);
44
- }
45
- }
46
-
47
- /** Whether the listener is meant to be connected right now. */
48
- get active(): boolean {
49
- return this.running;
50
- }
51
-
52
- /**
53
- * Connect and begin listening. Idempotent.
54
- *
55
- * Rejects if the *initial* connection or `LISTEN` fails, leaving the
56
- * listener stopped — callers use that to degrade deliberately instead of
57
- * running blind against a channel nothing is delivering.
58
- */
59
- async start(): Promise<void> {
60
- if (this.running) return;
61
- this.running = true;
62
- try {
63
- await this.connect({ initial: true });
64
- } catch (err) {
65
- this.running = false;
66
- throw err;
67
- }
68
- }
69
-
70
- /** Stop listening and release the connection. Idempotent. */
71
- async stop(): Promise<void> {
72
- this.running = false;
73
- if (this.reconnectTimer) {
74
- clearTimeout(this.reconnectTimer);
75
- this.reconnectTimer = undefined;
76
- }
77
- if (this.client) {
78
- try {
79
- await this.client.end();
80
- } catch { /* ignore close errors */ }
81
- this.client = undefined;
82
- }
83
- }
84
-
85
- private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
86
- const { connectionString, channel, onPayload, logLabel } = this.options;
87
- // Held here rather than only inside the `try` so the failure path can
88
- // still reach it: everything below `connect()` can throw, and until
89
- // `this.client` is assigned nothing else in this class knows the
90
- // connection exists. Left unreleased it stays open on the server while
91
- // `scheduleReconnect` opens another — one leaked backend per attempt,
92
- // every few seconds, for as long as the failure lasts.
93
- let pending: PgClient | undefined;
94
- try {
95
- const client = new PgClient({ connectionString });
96
- pending = client;
97
-
98
- client.on("error", (err) => {
99
- logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
100
- this.scheduleReconnect();
101
- });
102
-
103
- client.on("end", () => {
104
- if (this.running) {
105
- logger.warn(`⚠️ ${logLabel} LISTEN client disconnected unexpectedly.`);
106
- this.scheduleReconnect();
107
- }
108
- });
109
-
110
- client.on("notification", (msg) => {
111
- if (!msg.payload) return;
112
- // A handler rejection must never surface as an unhandled
113
- // rejection inside the pg client's event emitter.
114
- Promise.resolve(onPayload(msg.payload)).catch((err) =>
115
- logger.error(`❌ ${logLabel} Error handling notification`, { error: err })
116
- );
117
- });
118
-
119
- await client.connect();
120
- await client.query(`LISTEN ${channel}`);
121
- this.client = client;
122
- // Adopted: `stop()` and `scheduleReconnect` will close it now.
123
- pending = undefined;
124
- logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
125
- } catch (err) {
126
- // Never adopted, so nothing else will ever close it.
127
- if (pending) {
128
- try { await pending.end(); } catch { /* already dead */ }
129
- }
130
- // Surface the initial failure so callers can choose to fall back;
131
- // for reconnects, keep retrying quietly in the background.
132
- if (initial) throw err;
133
- logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
134
- this.scheduleReconnect();
135
- }
136
- }
137
-
138
- private scheduleReconnect(): void {
139
- if (!this.running || this.reconnectTimer) return;
140
-
141
- this.reconnectTimer = setTimeout(async () => {
142
- this.reconnectTimer = undefined;
143
- if (!this.running) return;
144
- if (this.client) {
145
- try { await this.client.end(); } catch { /* ignore */ }
146
- this.client = undefined;
147
- }
148
- await this.connect();
149
- }, this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
150
- }
151
- }