@rebasepro/server-postgres 0.9.1-canary.a57c262 → 0.9.1-canary.ad25bc0
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/data-transformer.d.ts +2 -9
- package/dist/index.es.js +298 -639
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +32 -4
- package/dist/services/RelationService.d.ts +1 -34
- package/dist/services/collection-helpers.d.ts +0 -76
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +0 -7
- package/package.json +7 -10
- package/src/PostgresBackendDriver.ts +6 -21
- package/src/PostgresBootstrapper.ts +23 -11
- package/src/data-transformer.ts +9 -11
- package/src/schema/generate-drizzle-schema-logic.ts +4 -21
- package/src/schema/generate-postgres-ddl-logic.ts +3 -63
- package/src/schema/introspect-db.ts +1 -8
- package/src/services/FetchService.ts +229 -50
- package/src/services/RelationService.ts +94 -153
- package/src/services/collection-helpers.ts +3 -166
- package/src/services/index.ts +0 -1
- package/src/services/realtimeService.ts +19 -40
- package/dist/collections/buildRegistry.d.ts +0 -27
- package/dist/services/row-pipeline.d.ts +0 -60
- package/src/collections/buildRegistry.ts +0 -59
- package/src/services/row-pipeline.ts +0 -176
|
@@ -2,14 +2,12 @@ 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
5
|
|
|
7
6
|
// Row identity is derived on both sides of the wire — the driver parses an
|
|
8
7
|
// incoming address into key columns, the admin derives one from a served row —
|
|
9
8
|
// so the implementation lives in `common` and both agree by construction.
|
|
10
9
|
export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
11
10
|
export type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
12
|
-
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
|
|
13
11
|
import type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
14
12
|
|
|
15
13
|
/**
|
|
@@ -58,27 +56,10 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
|
|
|
58
56
|
return table;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
/**
|
|
62
|
-
* The key columns a collection's rows are addressed by.
|
|
63
|
-
*
|
|
64
|
-
* Three tiers, in order: properties marked `isId`, the primary keys of the
|
|
65
|
-
* drizzle schema, and finally a column literally named `id`. Only the first is
|
|
66
|
-
* visible to the browser, which is why a key known only to drizzle is reported
|
|
67
|
-
* at boot — see {@link warnOnKeysTheAdminCannotResolve}.
|
|
68
|
-
*
|
|
69
|
-
* Returns `[]` when nothing resolves, rather than throwing. It used to open by
|
|
70
|
-
* resolving the table, which throws when there is none — so the `isId` tier,
|
|
71
|
-
* which needs no table at all, was unreachable for exactly the collections
|
|
72
|
-
* most likely to have no table registered. Every caller that wanted "no keys"
|
|
73
|
-
* to mean "no keys" had to spell that out in a try/catch.
|
|
74
|
-
*
|
|
75
|
-
* Callers that cannot proceed without a key must say so themselves, naming the
|
|
76
|
-
* collection: an empty array here means "this collection has no address", which
|
|
77
|
-
* is a different answer in a notification (broadcast a wildcard) than in a save
|
|
78
|
-
* (fail).
|
|
79
|
-
*/
|
|
80
59
|
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
|
|
81
|
-
|
|
60
|
+
const table = getTableForCollection(collection, registry);
|
|
61
|
+
|
|
62
|
+
// Fallback to explicitly defined isId properties
|
|
82
63
|
if (collection.properties) {
|
|
83
64
|
const idProps = Object.entries(collection.properties)
|
|
84
65
|
.filter(([_, prop]) => "isId" in (prop as object) && Boolean((prop as { isId?: unknown }).isId))
|
|
@@ -93,12 +74,6 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
|
|
|
93
74
|
}
|
|
94
75
|
}
|
|
95
76
|
|
|
96
|
-
// The remaining tiers read the drizzle schema, so they need the table. A
|
|
97
|
-
// collection without one — another engine's, or simply unregistered — has
|
|
98
|
-
// nothing more to offer.
|
|
99
|
-
const table = registry.getTable(getTableName(collection));
|
|
100
|
-
if (!table) return [];
|
|
101
|
-
|
|
102
77
|
// Otherwise infer from Drizzle schema
|
|
103
78
|
const keys: PrimaryKeyInfo[] = [];
|
|
104
79
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
@@ -128,141 +103,3 @@ isUUID });
|
|
|
128
103
|
return keys;
|
|
129
104
|
}
|
|
130
105
|
|
|
131
|
-
/**
|
|
132
|
-
* The key columns, for callers that cannot do their job without one.
|
|
133
|
-
*
|
|
134
|
-
* {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
|
|
135
|
-
* collection with no address. Most of this driver, though, is building a WHERE
|
|
136
|
-
* clause and has no meaning without a key — for those, an empty array is not an
|
|
137
|
-
* answer, and indexing `[0]` into it produces `Cannot read properties of
|
|
138
|
-
* undefined` three frames from where the real problem is. This says what is
|
|
139
|
-
* wrong and which collection it is wrong about.
|
|
140
|
-
*/
|
|
141
|
-
export function requirePrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
|
|
142
|
-
const keys = getPrimaryKeys(collection, registry);
|
|
143
|
-
if (keys.length === 0) {
|
|
144
|
-
throw new Error(
|
|
145
|
-
`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. ` +
|
|
146
|
-
`Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
return keys;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
154
|
-
* instead.
|
|
155
|
-
*
|
|
156
|
-
* The two sides resolve keys from different evidence. This driver reads, in
|
|
157
|
-
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
158
|
-
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
159
|
-
* compiles the same collection files into its bundle — but never the Drizzle
|
|
160
|
-
* schema, so the middle tier is invisible to it.
|
|
161
|
-
*
|
|
162
|
-
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
163
|
-
* its collections, so a key resolved here cannot be handed over there. The
|
|
164
|
-
* config files are the only thing both sides read, so the fix is an edit to
|
|
165
|
-
* them, and the most this can do is say exactly which edit.
|
|
166
|
-
*
|
|
167
|
-
* Two shapes, and the second is the dangerous one:
|
|
168
|
-
*
|
|
169
|
-
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
170
|
-
* console, and rows cannot be opened or linked.
|
|
171
|
-
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
172
|
-
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
173
|
-
* errors: the addresses look right and route wrong.
|
|
174
|
-
*/
|
|
175
|
-
export function findUnresolvableKeyCollections(
|
|
176
|
-
collections: CollectionConfig[],
|
|
177
|
-
registry: PostgresCollectionRegistry
|
|
178
|
-
): { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] {
|
|
179
|
-
const findings: { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] = [];
|
|
180
|
-
|
|
181
|
-
for (const collection of collections) {
|
|
182
|
-
// Declared `isId` is the tier both sides share: if it is there, they agree.
|
|
183
|
-
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
184
|
-
|
|
185
|
-
// No registered table resolves to no keys, and a collection this cannot
|
|
186
|
-
// resolve a key for is one it has nothing to say about.
|
|
187
|
-
const keys = getPrimaryKeys(collection, registry);
|
|
188
|
-
if (keys.length === 0) continue;
|
|
189
|
-
|
|
190
|
-
// A single key named `id` is the last tier on both sides: they agree
|
|
191
|
-
// without anything being declared.
|
|
192
|
-
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
193
|
-
|
|
194
|
-
findings.push({
|
|
195
|
-
collection,
|
|
196
|
-
keys,
|
|
197
|
-
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return findings;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Report the collections from {@link findUnresolvableKeyCollections} at boot,
|
|
206
|
-
* with the edit that fixes each one.
|
|
207
|
-
*
|
|
208
|
-
* Grouped by failure, not by collection: the shadowed case is a routing bug and
|
|
209
|
-
* the silent case is a missing feature, and they deserve different urgency.
|
|
210
|
-
*/
|
|
211
|
-
export function warnOnKeysTheAdminCannotResolve(
|
|
212
|
-
collections: CollectionConfig[],
|
|
213
|
-
registry: PostgresCollectionRegistry
|
|
214
|
-
): void {
|
|
215
|
-
const findings = findUnresolvableKeyCollections(collections, registry);
|
|
216
|
-
if (findings.length === 0) return;
|
|
217
|
-
|
|
218
|
-
const edit = (f: { collection: CollectionConfig; keys: PrimaryKeyInfo[] }) =>
|
|
219
|
-
`${f.collection.slug}: mark ${f.keys.map(k => `\`${k.fieldName}\``).join(" and ")} with ` +
|
|
220
|
-
`\`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
|
|
221
|
-
|
|
222
|
-
const shadowed = findings.filter(f => f.shadowedByIdProperty);
|
|
223
|
-
const silent = findings.filter(f => !f.shadowedByIdProperty);
|
|
224
|
-
|
|
225
|
-
if (shadowed.length > 0) {
|
|
226
|
-
logger.warn(
|
|
227
|
-
`⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema — but they ` +
|
|
228
|
-
`do have a property called \`id\`. The admin has no way to know \`id\` is not the key, so it will ` +
|
|
229
|
-
`address rows by it while this server reads the address as the real key: the links look right and ` +
|
|
230
|
-
`route wrong. Nothing will error.\n\n` +
|
|
231
|
-
shadowed.map(f => ` • ${edit(f)}`).join("\n") + "\n"
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (silent.length > 0) {
|
|
236
|
-
logger.warn(
|
|
237
|
-
`⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema, which the ` +
|
|
238
|
-
`admin never sees. It will resolve no address for their rows, so detail links, caching and ` +
|
|
239
|
-
`relations will not work for them:\n\n` +
|
|
240
|
-
silent.map(f => ` • ${edit(f)}`).join("\n") + "\n"
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* The address of a row: derived from the collection's primary keys, because a
|
|
247
|
-
* row does not carry one — it is exactly its columns.
|
|
248
|
-
*
|
|
249
|
-
* Falls back to a literal `id` column, for a row that reached us from somewhere
|
|
250
|
-
* other than this driver. Returns `""` when there is no key and no `id` —
|
|
251
|
-
* callers decide what that means, since "unaddressable" is a different answer
|
|
252
|
-
* in a notification (broadcast a wildcard) than in a save (fail).
|
|
253
|
-
*/
|
|
254
|
-
export function deriveRowAddress(
|
|
255
|
-
row: Record<string, unknown>,
|
|
256
|
-
collection: CollectionConfig,
|
|
257
|
-
registry: PostgresCollectionRegistry
|
|
258
|
-
): string {
|
|
259
|
-
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
260
|
-
// An all-empty composite is indistinguishable from a missing key, and
|
|
261
|
-
// `buildCompositeId` returns "" for no keys at all.
|
|
262
|
-
if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
|
|
263
|
-
return composite;
|
|
264
|
-
}
|
|
265
|
-
if (row.id !== undefined && row.id !== null) return String(row.id);
|
|
266
|
-
return "";
|
|
267
|
-
}
|
|
268
|
-
|
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,45 +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
|
-
// `getCollectionByPath` throws on a path it cannot walk — and this
|
|
967
|
-
// is called for parent paths too, which include entity paths like
|
|
968
|
-
// `posts/1` that name no collection. Telling the subscriber nothing
|
|
969
|
-
// is right here; letting it throw would drop the notification.
|
|
970
|
-
return undefined;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
945
|
private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
|
|
975
946
|
const message = {
|
|
976
947
|
type: "error" as const,
|
|
@@ -1323,9 +1294,17 @@ lastSeen: Date.now() });
|
|
|
1323
1294
|
|
|
1324
1295
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
1325
1296
|
private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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 "*";
|
|
1329
1308
|
}
|
|
1330
1309
|
|
|
1331
1310
|
// ── App/CDC de-duplication ──
|
|
@@ -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,60 +0,0 @@
|
|
|
1
|
-
import { CollectionConfig, Relation } from "@rebasepro/types";
|
|
2
|
-
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
3
|
-
/**
|
|
4
|
-
* Turning a drizzle result into a row we serve.
|
|
5
|
-
*
|
|
6
|
-
* There are two shapes, and they are the same walk. Both take a row whose
|
|
7
|
-
* relation fields hold nested objects, both unwrap junction rows to reach the
|
|
8
|
-
* target behind them, and both leave every other column alone. They differ only
|
|
9
|
-
* in what they put where the relation was:
|
|
10
|
-
*
|
|
11
|
-
* - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
|
|
12
|
-
* target's values. This is what the admin renders.
|
|
13
|
-
* - `"inline"` — the target's own columns, flat. This is what REST serves.
|
|
14
|
-
*
|
|
15
|
-
* They used to be two functions that happened to agree, and the agreement was
|
|
16
|
-
* not enforced by anything: the row-identity bug had to be fixed five times
|
|
17
|
-
* across differently-shaped copies of this walk, and one of the copies was
|
|
18
|
-
* dead code nobody had noticed. Whatever the next cross-cutting change is, it
|
|
19
|
-
* is one edit here.
|
|
20
|
-
*/
|
|
21
|
-
export type RelationStyle = "ref" | "inline";
|
|
22
|
-
/**
|
|
23
|
-
* Whether a many-relation reaches its target through a junction table.
|
|
24
|
-
*
|
|
25
|
-
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
26
|
-
* the query has to nest one level deeper for a junction, and the row walk has
|
|
27
|
-
* to unwrap that same level back out.
|
|
28
|
-
*/
|
|
29
|
-
export declare function isJunctionRelation(relation: Relation): boolean;
|
|
30
|
-
/**
|
|
31
|
-
* The address a relation ref points at.
|
|
32
|
-
*
|
|
33
|
-
* The whole key, not its first column: a composite-keyed target addressed by
|
|
34
|
-
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
35
|
-
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
36
|
-
* array — taking down the parent's fetch over a relation it may not even have
|
|
37
|
-
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
38
|
-
* beats no rows at all.
|
|
39
|
-
*/
|
|
40
|
-
export declare function relationTargetAddress(targetRow: Record<string, unknown>, targetCollection: CollectionConfig, registry: PostgresCollectionRegistry): string;
|
|
41
|
-
/**
|
|
42
|
-
* The row the admin renders: every column, with relations as references.
|
|
43
|
-
*
|
|
44
|
-
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
45
|
-
* expects real types. The row's own address is *not* among the columns — it is
|
|
46
|
-
* derived by the consumer from the collection's primary keys.
|
|
47
|
-
*/
|
|
48
|
-
export declare function toCmsRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
|
|
49
|
-
/**
|
|
50
|
-
* The row REST serves: every column under its own name, with the value Postgres
|
|
51
|
-
* returned, and relations inlined as the target's columns.
|
|
52
|
-
*
|
|
53
|
-
* Deliberately not normalized: REST serves what the database holds, and JSON
|
|
54
|
-
* has its own opinions about dates that the admin's view-model does not share.
|
|
55
|
-
*
|
|
56
|
-
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
57
|
-
* the relations `include` asked for, so the row is the authority on which are
|
|
58
|
-
* actually there.
|
|
59
|
-
*/
|
|
60
|
-
export declare function toRestRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
|
|
@@ -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
|
-
}
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
import { CollectionConfig, Relation } from "@rebasepro/types";
|
|
2
|
-
import { resolveCollectionRelations, findRelation, createRelationRefWithData } from "@rebasepro/common";
|
|
3
|
-
import { normalizeDbValues } from "../data-transformer";
|
|
4
|
-
import { deriveRowAddress } from "./collection-helpers";
|
|
5
|
-
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Turning a drizzle result into a row we serve.
|
|
9
|
-
*
|
|
10
|
-
* There are two shapes, and they are the same walk. Both take a row whose
|
|
11
|
-
* relation fields hold nested objects, both unwrap junction rows to reach the
|
|
12
|
-
* target behind them, and both leave every other column alone. They differ only
|
|
13
|
-
* in what they put where the relation was:
|
|
14
|
-
*
|
|
15
|
-
* - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
|
|
16
|
-
* target's values. This is what the admin renders.
|
|
17
|
-
* - `"inline"` — the target's own columns, flat. This is what REST serves.
|
|
18
|
-
*
|
|
19
|
-
* They used to be two functions that happened to agree, and the agreement was
|
|
20
|
-
* not enforced by anything: the row-identity bug had to be fixed five times
|
|
21
|
-
* across differently-shaped copies of this walk, and one of the copies was
|
|
22
|
-
* dead code nobody had noticed. Whatever the next cross-cutting change is, it
|
|
23
|
-
* is one edit here.
|
|
24
|
-
*/
|
|
25
|
-
export type RelationStyle = "ref" | "inline";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Whether a many-relation reaches its target through a junction table.
|
|
29
|
-
*
|
|
30
|
-
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
31
|
-
* the query has to nest one level deeper for a junction, and the row walk has
|
|
32
|
-
* to unwrap that same level back out.
|
|
33
|
-
*/
|
|
34
|
-
export function isJunctionRelation(relation: Relation): boolean {
|
|
35
|
-
// An explicit `through` says so outright.
|
|
36
|
-
if (relation.through) return true;
|
|
37
|
-
// A joinPath with an intermediate table is the same thing, spelled longhand.
|
|
38
|
-
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Reach the target row inside a junction row.
|
|
44
|
-
*
|
|
45
|
-
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
46
|
-
* the foreign keys, and the target nested under one of them. The target is the
|
|
47
|
-
* only object among them, so that is how it is found. A junction row that has
|
|
48
|
-
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
49
|
-
*/
|
|
50
|
-
function unwrapJunctionRow(item: Record<string, unknown>): Record<string, unknown> {
|
|
51
|
-
const nestedKey = Object.keys(item).find(
|
|
52
|
-
key => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key])
|
|
53
|
-
);
|
|
54
|
-
return nestedKey ? item[nestedKey] as Record<string, unknown> : item;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Render one target row in the requested style. */
|
|
58
|
-
function renderTarget(
|
|
59
|
-
targetRow: Record<string, unknown>,
|
|
60
|
-
targetCollection: CollectionConfig,
|
|
61
|
-
style: RelationStyle,
|
|
62
|
-
registry: PostgresCollectionRegistry
|
|
63
|
-
): unknown {
|
|
64
|
-
if (style === "inline") {
|
|
65
|
-
// The target's columns, and only those: its address is the consumer's
|
|
66
|
-
// to derive, and merging one in overwrites a real `id` column.
|
|
67
|
-
return { ...targetRow };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
71
|
-
const path = targetCollection.slug;
|
|
72
|
-
return createRelationRefWithData(address, path, {
|
|
73
|
-
id: address,
|
|
74
|
-
path,
|
|
75
|
-
values: normalizeDbValues(targetRow, targetCollection)
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The address a relation ref points at.
|
|
81
|
-
*
|
|
82
|
-
* The whole key, not its first column: a composite-keyed target addressed by
|
|
83
|
-
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
84
|
-
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
85
|
-
* array — taking down the parent's fetch over a relation it may not even have
|
|
86
|
-
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
87
|
-
* beats no rows at all.
|
|
88
|
-
*/
|
|
89
|
-
export function relationTargetAddress(
|
|
90
|
-
targetRow: Record<string, unknown>,
|
|
91
|
-
targetCollection: CollectionConfig,
|
|
92
|
-
registry: PostgresCollectionRegistry
|
|
93
|
-
): string {
|
|
94
|
-
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
95
|
-
if (address) return address;
|
|
96
|
-
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The row the admin renders: every column, with relations as references.
|
|
101
|
-
*
|
|
102
|
-
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
103
|
-
* expects real types. The row's own address is *not* among the columns — it is
|
|
104
|
-
* derived by the consumer from the collection's primary keys.
|
|
105
|
-
*/
|
|
106
|
-
export function toCmsRow(
|
|
107
|
-
row: Record<string, unknown>,
|
|
108
|
-
collection: CollectionConfig,
|
|
109
|
-
registry: PostgresCollectionRegistry
|
|
110
|
-
): Record<string, unknown> {
|
|
111
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
112
|
-
const normalized = normalizeDbValues(row, collection) as Record<string, unknown>;
|
|
113
|
-
|
|
114
|
-
// Keyed by relation, reading the drizzle relation name off the raw row: the
|
|
115
|
-
// relation's property key and the name drizzle nested it under are not
|
|
116
|
-
// always the same, and the value is written back under the property key.
|
|
117
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
118
|
-
const relData = row[relation.relationName || key];
|
|
119
|
-
if (relData === undefined || relData === null) continue;
|
|
120
|
-
|
|
121
|
-
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
122
|
-
const targetCollection = relation.target();
|
|
123
|
-
normalized[key] = relData.map((item: Record<string, unknown>) =>
|
|
124
|
-
renderTarget(
|
|
125
|
-
isJunctionRelation(relation) ? unwrapJunctionRow(item) : item,
|
|
126
|
-
targetCollection,
|
|
127
|
-
"ref",
|
|
128
|
-
registry
|
|
129
|
-
));
|
|
130
|
-
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
131
|
-
normalized[key] = renderTarget(relData as Record<string, unknown>, relation.target(), "ref", registry);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return normalized;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* The row REST serves: every column under its own name, with the value Postgres
|
|
140
|
-
* returned, and relations inlined as the target's columns.
|
|
141
|
-
*
|
|
142
|
-
* Deliberately not normalized: REST serves what the database holds, and JSON
|
|
143
|
-
* has its own opinions about dates that the admin's view-model does not share.
|
|
144
|
-
*
|
|
145
|
-
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
146
|
-
* the relations `include` asked for, so the row is the authority on which are
|
|
147
|
-
* actually there.
|
|
148
|
-
*/
|
|
149
|
-
export function toRestRow(
|
|
150
|
-
row: Record<string, unknown>,
|
|
151
|
-
collection: CollectionConfig,
|
|
152
|
-
registry: PostgresCollectionRegistry
|
|
153
|
-
): Record<string, unknown> {
|
|
154
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
155
|
-
const flat: Record<string, unknown> = {};
|
|
156
|
-
|
|
157
|
-
for (const [key, value] of Object.entries(row)) {
|
|
158
|
-
const relation = findRelation(resolvedRelations, key);
|
|
159
|
-
|
|
160
|
-
if (relation && Array.isArray(value)) {
|
|
161
|
-
flat[key] = value.map((item: Record<string, unknown>) =>
|
|
162
|
-
renderTarget(
|
|
163
|
-
isJunctionRelation(relation) ? unwrapJunctionRow(item) : item,
|
|
164
|
-
relation.target(),
|
|
165
|
-
"inline",
|
|
166
|
-
registry
|
|
167
|
-
));
|
|
168
|
-
} else if (relation && typeof value === "object" && value !== null) {
|
|
169
|
-
flat[key] = renderTarget(value as Record<string, unknown>, relation.target(), "inline", registry);
|
|
170
|
-
} else {
|
|
171
|
-
flat[key] = value;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return flat;
|
|
176
|
-
}
|