@rebasepro/server-postgres 0.9.1-canary.58368ce → 0.9.1-canary.73476f2
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/dist/PostgresBackendDriver.d.ts +2 -25
- package/dist/data-transformer.d.ts +2 -9
- package/dist/index.es.js +388 -1069
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +7 -21
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/RelationService.d.ts +1 -0
- package/dist/services/collection-helpers.d.ts +14 -52
- package/dist/services/dataService.d.ts +1 -3
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +0 -7
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +8 -109
- package/src/PostgresBootstrapper.ts +23 -11
- package/src/data-transformer.ts +9 -11
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/generate-drizzle-schema-logic.ts +29 -24
- package/src/schema/generate-postgres-ddl-logic.ts +28 -76
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +155 -84
- package/src/services/PersistService.ts +12 -121
- package/src/services/RelationService.ts +11 -10
- package/src/services/collection-helpers.ts +44 -129
- package/src/services/dataService.ts +2 -3
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -38
- package/src/websocket.ts +1 -4
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/src/collections/buildRegistry.ts +0 -59
|
@@ -162,12 +162,11 @@ export class RelationService {
|
|
|
162
162
|
const rows: RelatedRow<M>[] = [];
|
|
163
163
|
for (const row of results as Array<Record<string, unknown>>) {
|
|
164
164
|
const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
|
|
165
|
+
const id = targetRow[idInfo[0].fieldName as string];
|
|
165
166
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
166
167
|
|
|
167
168
|
rows.push({
|
|
168
|
-
|
|
169
|
-
// every row that shares it.
|
|
170
|
-
id: buildCompositeId(targetRow, idInfo),
|
|
169
|
+
id: id?.toString() || "",
|
|
171
170
|
path: targetCollection.slug,
|
|
172
171
|
values: parsedValues as M
|
|
173
172
|
});
|
|
@@ -229,10 +228,11 @@ export class RelationService {
|
|
|
229
228
|
const rows: RelatedRow<M>[] = [];
|
|
230
229
|
for (const row of results) {
|
|
231
230
|
const targetRow = row[getTableName(targetCollection)] || row;
|
|
231
|
+
const id = targetRow[idInfo[0].fieldName];
|
|
232
232
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
|
|
233
233
|
|
|
234
234
|
rows.push({
|
|
235
|
-
id:
|
|
235
|
+
id: id?.toString() || "",
|
|
236
236
|
path: targetCollection.slug,
|
|
237
237
|
values: parsedValues as M
|
|
238
238
|
});
|
|
@@ -371,7 +371,7 @@ export class RelationService {
|
|
|
371
371
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
372
372
|
|
|
373
373
|
resultMap.set(String(parentId), {
|
|
374
|
-
id:
|
|
374
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
375
375
|
path: targetCollection.slug,
|
|
376
376
|
values: parsedValues as Record<string, unknown>
|
|
377
377
|
});
|
|
@@ -438,7 +438,7 @@ export class RelationService {
|
|
|
438
438
|
if (targetRow) {
|
|
439
439
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
440
440
|
resultMap.set(parentIdStr, {
|
|
441
|
-
id:
|
|
441
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
442
442
|
path: targetCollection.slug,
|
|
443
443
|
values: parsedValues as Record<string, unknown>
|
|
444
444
|
});
|
|
@@ -490,7 +490,7 @@ export class RelationService {
|
|
|
490
490
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
491
491
|
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
492
492
|
resultMap.set(String(parentId), {
|
|
493
|
-
id:
|
|
493
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
494
494
|
path: targetCollection.slug,
|
|
495
495
|
values: parsedValues as Record<string, unknown>
|
|
496
496
|
});
|
|
@@ -565,7 +565,7 @@ export class RelationService {
|
|
|
565
565
|
|
|
566
566
|
const arr = resultMap.get(parentId) || [];
|
|
567
567
|
arr.push({
|
|
568
|
-
id:
|
|
568
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
569
569
|
path: targetCollection.slug,
|
|
570
570
|
values: parsedValues as Record<string, unknown>
|
|
571
571
|
});
|
|
@@ -616,7 +616,7 @@ export class RelationService {
|
|
|
616
616
|
|
|
617
617
|
const arr = resultMap.get(parentId) || [];
|
|
618
618
|
arr.push({
|
|
619
|
-
id:
|
|
619
|
+
id: String(targetData[targetIdInfo.fieldName]),
|
|
620
620
|
path: targetCollection.slug,
|
|
621
621
|
values: parsedValues as Record<string, unknown>
|
|
622
622
|
});
|
|
@@ -672,7 +672,7 @@ export class RelationService {
|
|
|
672
672
|
const key = String(parentId);
|
|
673
673
|
const arr = resultMap.get(key) || [];
|
|
674
674
|
arr.push({
|
|
675
|
-
id:
|
|
675
|
+
id: String(targetRow[targetIdInfo.fieldName]),
|
|
676
676
|
path: targetCollection.slug,
|
|
677
677
|
values: parsedValues as Record<string, unknown>
|
|
678
678
|
});
|
|
@@ -865,6 +865,7 @@ export class RelationService {
|
|
|
865
865
|
relationKey: string;
|
|
866
866
|
relation: Relation;
|
|
867
867
|
newValue: unknown;
|
|
868
|
+
currentId?: string | number;
|
|
868
869
|
}>
|
|
869
870
|
) {
|
|
870
871
|
for (const update of inverseRelationUpdates) {
|
|
@@ -2,15 +2,6 @@ import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
|
2
2
|
import { CollectionConfig, Property } from "@rebasepro/types";
|
|
3
3
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
4
|
import { getTableName } from "@rebasepro/common";
|
|
5
|
-
import { logger } from "@rebasepro/server";
|
|
6
|
-
|
|
7
|
-
// Row identity is derived on both sides of the wire — the driver parses an
|
|
8
|
-
// incoming address into key columns, the admin derives one from a served row —
|
|
9
|
-
// so the implementation lives in `common` and both agree by construction.
|
|
10
|
-
export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
11
|
-
export type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
12
|
-
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
|
|
13
|
-
import type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
14
5
|
|
|
15
6
|
/**
|
|
16
7
|
* Shared helper functions for row operations.
|
|
@@ -58,7 +49,7 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
|
|
|
58
49
|
return table;
|
|
59
50
|
}
|
|
60
51
|
|
|
61
|
-
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry):
|
|
52
|
+
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] {
|
|
62
53
|
const table = getTableForCollection(collection, registry);
|
|
63
54
|
|
|
64
55
|
// Fallback to explicitly defined isId properties
|
|
@@ -77,7 +68,7 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
|
|
|
77
68
|
}
|
|
78
69
|
|
|
79
70
|
// Otherwise infer from Drizzle schema
|
|
80
|
-
const keys:
|
|
71
|
+
const keys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] = [];
|
|
81
72
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
82
73
|
const col = colRaw as AnyPgColumn;
|
|
83
74
|
if (col && typeof col === "object" && "primary" in col && col.primary) {
|
|
@@ -105,132 +96,56 @@ isUUID });
|
|
|
105
96
|
return keys;
|
|
106
97
|
}
|
|
107
98
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
* instead.
|
|
111
|
-
*
|
|
112
|
-
* The two sides resolve keys from different evidence. This driver reads, in
|
|
113
|
-
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
114
|
-
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
115
|
-
* compiles the same collection files into its bundle — but never the Drizzle
|
|
116
|
-
* schema, so the middle tier is invisible to it.
|
|
117
|
-
*
|
|
118
|
-
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
119
|
-
* its collections, so a key resolved here cannot be handed over there. The
|
|
120
|
-
* config files are the only thing both sides read, so the fix is an edit to
|
|
121
|
-
* them, and the most this can do is say exactly which edit.
|
|
122
|
-
*
|
|
123
|
-
* Two shapes, and the second is the dangerous one:
|
|
124
|
-
*
|
|
125
|
-
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
126
|
-
* console, and rows cannot be opened or linked.
|
|
127
|
-
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
128
|
-
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
129
|
-
* errors: the addresses look right and route wrong.
|
|
130
|
-
*/
|
|
131
|
-
export function findUnresolvableKeyCollections(
|
|
132
|
-
collections: CollectionConfig[],
|
|
133
|
-
registry: PostgresCollectionRegistry
|
|
134
|
-
): { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] {
|
|
135
|
-
const findings: { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] = [];
|
|
136
|
-
|
|
137
|
-
for (const collection of collections) {
|
|
138
|
-
// Declared `isId` is the tier both sides share: if it is there, they agree.
|
|
139
|
-
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
140
|
-
|
|
141
|
-
let keys: PrimaryKeyInfo[];
|
|
142
|
-
try {
|
|
143
|
-
keys = getPrimaryKeys(collection, registry);
|
|
144
|
-
} catch {
|
|
145
|
-
// No registered table — nothing resolved here either, so there is no
|
|
146
|
-
// disagreement to report.
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
if (keys.length === 0) continue;
|
|
150
|
-
|
|
151
|
-
// A single key named `id` is the last tier on both sides: they agree
|
|
152
|
-
// without anything being declared.
|
|
153
|
-
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
99
|
+
export function parseIdValues(idValue: string | number, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): Record<string, string | number> {
|
|
100
|
+
const result: Record<string, string | number> = {};
|
|
154
101
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
keys,
|
|
158
|
-
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
159
|
-
});
|
|
102
|
+
if (primaryKeys.length === 0) {
|
|
103
|
+
return result;
|
|
160
104
|
}
|
|
161
105
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
registry: PostgresCollectionRegistry
|
|
175
|
-
): void {
|
|
176
|
-
const findings = findUnresolvableKeyCollections(collections, registry);
|
|
177
|
-
if (findings.length === 0) return;
|
|
178
|
-
|
|
179
|
-
const edit = (f: { collection: CollectionConfig; keys: PrimaryKeyInfo[] }) =>
|
|
180
|
-
`${f.collection.slug}: mark ${f.keys.map(k => `\`${k.fieldName}\``).join(" and ")} with ` +
|
|
181
|
-
`\`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
|
|
182
|
-
|
|
183
|
-
const shadowed = findings.filter(f => f.shadowedByIdProperty);
|
|
184
|
-
const silent = findings.filter(f => !f.shadowedByIdProperty);
|
|
185
|
-
|
|
186
|
-
if (shadowed.length > 0) {
|
|
187
|
-
logger.warn(
|
|
188
|
-
`⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema — but they ` +
|
|
189
|
-
`do have a property called \`id\`. The admin has no way to know \`id\` is not the key, so it will ` +
|
|
190
|
-
`address rows by it while this server reads the address as the real key: the links look right and ` +
|
|
191
|
-
`route wrong. Nothing will error.\n\n` +
|
|
192
|
-
shadowed.map(f => ` • ${edit(f)}`).join("\n") + "\n"
|
|
193
|
-
);
|
|
106
|
+
if (primaryKeys.length === 1) {
|
|
107
|
+
const pk = primaryKeys[0];
|
|
108
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
109
|
+
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
110
|
+
if (isNaN(parsed)) {
|
|
111
|
+
throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
112
|
+
}
|
|
113
|
+
result[pk.fieldName] = parsed;
|
|
114
|
+
} else {
|
|
115
|
+
result[pk.fieldName] = String(idValue);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
194
118
|
}
|
|
195
119
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
`relations will not work for them:\n\n` +
|
|
201
|
-
silent.map(f => ` • ${edit(f)}`).join("\n") + "\n"
|
|
202
|
-
);
|
|
120
|
+
// Composite key - split by :::
|
|
121
|
+
const parts = String(idValue).split(":::");
|
|
122
|
+
if (parts.length !== primaryKeys.length) {
|
|
123
|
+
throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
203
124
|
}
|
|
204
|
-
}
|
|
205
125
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
*/
|
|
218
|
-
export function deriveRowAddress(
|
|
219
|
-
row: Record<string, unknown>,
|
|
220
|
-
collection: CollectionConfig,
|
|
221
|
-
registry: PostgresCollectionRegistry
|
|
222
|
-
): string {
|
|
223
|
-
try {
|
|
224
|
-
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
225
|
-
// An all-empty composite is indistinguishable from a missing key, and
|
|
226
|
-
// `buildCompositeId` returns "" for no keys at all.
|
|
227
|
-
if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
|
|
228
|
-
return composite;
|
|
126
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
127
|
+
const pk = primaryKeys[i];
|
|
128
|
+
const val = parts[i];
|
|
129
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
130
|
+
const parsed = parseInt(val, 10);
|
|
131
|
+
if (isNaN(parsed)) {
|
|
132
|
+
throw new Error(`Invalid numeric ID component: ${val}`);
|
|
133
|
+
}
|
|
134
|
+
result[pk.fieldName] = parsed;
|
|
135
|
+
} else {
|
|
136
|
+
result[pk.fieldName] = val;
|
|
229
137
|
}
|
|
230
|
-
} catch {
|
|
231
|
-
/* unresolvable table or keys — fall through to the literal column */
|
|
232
138
|
}
|
|
233
|
-
|
|
234
|
-
return
|
|
139
|
+
|
|
140
|
+
return result;
|
|
235
141
|
}
|
|
236
142
|
|
|
143
|
+
export function buildCompositeId(values: Record<string, unknown>, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): string {
|
|
144
|
+
if (primaryKeys.length === 0) {
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
if (primaryKeys.length === 1) {
|
|
148
|
+
return String(values[primaryKeys[0].fieldName] ?? "");
|
|
149
|
+
}
|
|
150
|
+
return primaryKeys.map(pk => String(values[pk.fieldName] ?? "")).join(":::");
|
|
151
|
+
}
|
|
@@ -154,10 +154,9 @@ export class DataService implements DataRepository {
|
|
|
154
154
|
collectionPath: string,
|
|
155
155
|
values: Partial<M>,
|
|
156
156
|
id?: string | number,
|
|
157
|
-
databaseId?: string
|
|
158
|
-
options?: { upsert?: boolean }
|
|
157
|
+
databaseId?: string
|
|
159
158
|
): Promise<Record<string, unknown>> {
|
|
160
|
-
return this.persistService.save<M>(collectionPath, values, id, databaseId
|
|
159
|
+
return this.persistService.save<M>(collectionPath, values, id, databaseId);
|
|
161
160
|
}
|
|
162
161
|
|
|
163
162
|
/**
|
package/src/services/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { applyAuthContext } from "../security/rls-enforcement";
|
|
|
14
14
|
import { logger } from "@rebasepro/server";
|
|
15
15
|
import { sanitizeErrorForClient } from "../utils/pg-error-utils";
|
|
16
16
|
import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
|
|
17
|
-
import {
|
|
17
|
+
import { getPrimaryKeys, buildCompositeId } from "./collection-helpers";
|
|
18
18
|
|
|
19
19
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
20
20
|
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -390,7 +390,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
390
390
|
authContext
|
|
391
391
|
);
|
|
392
392
|
|
|
393
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows
|
|
393
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
394
394
|
|
|
395
395
|
} catch (error) {
|
|
396
396
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -554,7 +554,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
554
554
|
// Phase 1: Send instant row-level patch (no DB query)
|
|
555
555
|
// This gives immediate cross-tab feedback
|
|
556
556
|
if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
|
|
557
|
-
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row
|
|
557
|
+
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
558
558
|
}
|
|
559
559
|
|
|
560
560
|
// Phase 2: Schedule a deferred full refetch for correctness
|
|
@@ -609,7 +609,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
609
609
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
610
610
|
try {
|
|
611
611
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
612
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows
|
|
612
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
613
613
|
} catch (error) {
|
|
614
614
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
615
615
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -910,12 +910,11 @@ roles: activeAuth.roles },
|
|
|
910
910
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
911
911
|
}
|
|
912
912
|
|
|
913
|
-
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[]
|
|
913
|
+
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[]) {
|
|
914
914
|
const message: CollectionUpdateMessage = {
|
|
915
915
|
type: "collection_update",
|
|
916
916
|
subscriptionId,
|
|
917
|
-
rows: rows
|
|
918
|
-
pks: this.primaryKeysForPath(path)
|
|
917
|
+
rows: rows
|
|
919
918
|
};
|
|
920
919
|
this.sendMessage(clientId, message);
|
|
921
920
|
}
|
|
@@ -932,43 +931,17 @@ roles: activeAuth.roles },
|
|
|
932
931
|
/**
|
|
933
932
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
934
933
|
* The client can merge this into its cached data for instant feedback.
|
|
935
|
-
*
|
|
936
|
-
* The key columns ride along: the patch names a row by address, and the
|
|
937
|
-
* client has to find that row among the ones it cached — which carry
|
|
938
|
-
* columns and no address. The SDK holds no collection config to derive one
|
|
939
|
-
* from, so this is the only place the mapping can come from.
|
|
940
934
|
*/
|
|
941
|
-
private sendCollectionPatch(
|
|
942
|
-
clientId: string,
|
|
943
|
-
subscriptionId: string,
|
|
944
|
-
id: string,
|
|
945
|
-
row: Record<string, unknown> | null,
|
|
946
|
-
notifyPath: string
|
|
947
|
-
) {
|
|
935
|
+
private sendCollectionPatch(clientId: string, subscriptionId: string, id: string, row: Record<string, unknown> | null) {
|
|
948
936
|
const message: CollectionPatchMessage = {
|
|
949
937
|
type: "collection_patch",
|
|
950
938
|
subscriptionId,
|
|
951
939
|
id,
|
|
952
|
-
row: row
|
|
953
|
-
pks: this.primaryKeysForPath(notifyPath)
|
|
940
|
+
row: row
|
|
954
941
|
};
|
|
955
942
|
this.sendMessage(clientId, message);
|
|
956
943
|
}
|
|
957
944
|
|
|
958
|
-
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
959
|
-
private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
|
|
960
|
-
try {
|
|
961
|
-
const collection = this.registry.getCollectionByPath(path);
|
|
962
|
-
if (!collection) return undefined;
|
|
963
|
-
const keys = getPrimaryKeys(collection, this.registry);
|
|
964
|
-
return keys.length > 0 ? keys : undefined;
|
|
965
|
-
} catch {
|
|
966
|
-
// Unregistered table, or a relation path with no collection of its
|
|
967
|
-
// own: the client keeps its pre-existing `id` behaviour.
|
|
968
|
-
return undefined;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
945
|
private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
|
|
973
946
|
const message = {
|
|
974
947
|
type: "error" as const,
|
|
@@ -1321,9 +1294,17 @@ lastSeen: Date.now() });
|
|
|
1321
1294
|
|
|
1322
1295
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
1323
1296
|
private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1297
|
+
try {
|
|
1298
|
+
const primaryKeys = getPrimaryKeys(collection, this.registry);
|
|
1299
|
+
const composite = buildCompositeId(row, primaryKeys);
|
|
1300
|
+
if (composite && composite !== ":::") return composite;
|
|
1301
|
+
} catch {
|
|
1302
|
+
/* fall through to id-field / wildcard */
|
|
1303
|
+
}
|
|
1304
|
+
// Fallbacks: a plain `id` column (common), else a collection-level
|
|
1305
|
+
// invalidation ("*") — single-row subs won't match but collection subs refetch.
|
|
1306
|
+
if (row.id !== undefined && row.id !== null) return String(row.id);
|
|
1307
|
+
return "*";
|
|
1327
1308
|
}
|
|
1328
1309
|
|
|
1329
1310
|
// ── App/CDC de-duplication ──
|
package/src/websocket.ts
CHANGED
|
@@ -620,12 +620,9 @@ roles: ["anon"] };
|
|
|
620
620
|
if (error instanceof Error) {
|
|
621
621
|
logger.error("Stack trace", { detail: error.stack });
|
|
622
622
|
}
|
|
623
|
-
// Unwrap the cause chain: a Drizzle failure reports itself as
|
|
624
|
-
// "Failed query: <sql> params:", which tells the user nothing and
|
|
625
|
-
// echoes the statement back at them. The reason is in the cause.
|
|
626
623
|
const errorMessage = process.env.NODE_ENV === "production"
|
|
627
624
|
? "An unexpected error occurred"
|
|
628
|
-
:
|
|
625
|
+
: (error instanceof Error ? error.message : "An unexpected error occurred");
|
|
629
626
|
const errorResponse = {
|
|
630
627
|
type: "ERROR",
|
|
631
628
|
requestId,
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { Relations } from "drizzle-orm";
|
|
2
|
-
import { PgEnum } from "drizzle-orm/pg-core";
|
|
3
|
-
import { CollectionConfig } from "@rebasepro/types";
|
|
4
|
-
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
5
|
-
/**
|
|
6
|
-
* Everything a registry is built from: the collections, and the drizzle schema
|
|
7
|
-
* they are backed by. In BaaS mode all of it is introspected from the live
|
|
8
|
-
* database; in CMS mode it comes from the config and the generated schema.
|
|
9
|
-
*/
|
|
10
|
-
export interface RegistrySchema {
|
|
11
|
-
collections?: CollectionConfig[];
|
|
12
|
-
tables?: Record<string, unknown>;
|
|
13
|
-
enums?: Record<string, PgEnum<[string, ...string[]]>>;
|
|
14
|
-
relations?: Record<string, Relations>;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Build the collection registry for a driver.
|
|
18
|
-
*
|
|
19
|
-
* The order matters and is the reason this is one function rather than a run of
|
|
20
|
-
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
21
|
-
* anything that inspects them has to run *after* the tables are registered —
|
|
22
|
-
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
23
|
-
* collection whose table it cannot look up is one it has nothing to say about.
|
|
24
|
-
* Warned too early, it would skip every collection and report nothing, which
|
|
25
|
-
* reads exactly like having nothing to report.
|
|
26
|
-
*/
|
|
27
|
-
export declare function buildCollectionRegistry(schema: RegistrySchema): PostgresCollectionRegistry;
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { isTable, getTableName, Relations } from "drizzle-orm";
|
|
2
|
-
import { PgEnum, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
-
import { CollectionConfig } from "@rebasepro/types";
|
|
4
|
-
import { logger } from "@rebasepro/server";
|
|
5
|
-
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
6
|
-
import { warnOnKeysTheAdminCannotResolve } from "../services/collection-helpers";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Everything a registry is built from: the collections, and the drizzle schema
|
|
10
|
-
* they are backed by. In BaaS mode all of it is introspected from the live
|
|
11
|
-
* database; in CMS mode it comes from the config and the generated schema.
|
|
12
|
-
*/
|
|
13
|
-
export interface RegistrySchema {
|
|
14
|
-
collections?: CollectionConfig[];
|
|
15
|
-
tables?: Record<string, unknown>;
|
|
16
|
-
enums?: Record<string, PgEnum<[string, ...string[]]>>;
|
|
17
|
-
relations?: Record<string, Relations>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Build the collection registry for a driver.
|
|
22
|
-
*
|
|
23
|
-
* The order matters and is the reason this is one function rather than a run of
|
|
24
|
-
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
25
|
-
* anything that inspects them has to run *after* the tables are registered —
|
|
26
|
-
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
27
|
-
* collection whose table it cannot look up is one it has nothing to say about.
|
|
28
|
-
* Warned too early, it would skip every collection and report nothing, which
|
|
29
|
-
* reads exactly like having nothing to report.
|
|
30
|
-
*/
|
|
31
|
-
export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollectionRegistry {
|
|
32
|
-
const registry = new PostgresCollectionRegistry();
|
|
33
|
-
|
|
34
|
-
if (schema.collections) {
|
|
35
|
-
registry.registerMultiple(schema.collections);
|
|
36
|
-
logger.info(
|
|
37
|
-
`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
|
|
38
|
-
`[${registry.getCollections().map(c => c.slug).join(", ")}]`
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (schema.tables) {
|
|
43
|
-
Object.values(schema.tables).forEach((table) => {
|
|
44
|
-
if (isTable(table)) {
|
|
45
|
-
registry.registerTable(table as PgTable, getTableName(table));
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (schema.enums) registry.registerEnums(schema.enums);
|
|
51
|
-
if (schema.relations) registry.registerRelations(schema.relations);
|
|
52
|
-
|
|
53
|
-
// Now that the keys resolve: say which of them the admin cannot see. It
|
|
54
|
-
// compiles the same collection files into its bundle but never the drizzle
|
|
55
|
-
// schema, and nothing serves it one, so only an edit to the config fixes it.
|
|
56
|
-
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
57
|
-
|
|
58
|
-
return registry;
|
|
59
|
-
}
|