@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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
}
|