@rebasepro/server-postgres 0.9.1-canary.7dddf96 → 0.9.1-canary.a57c262

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.
@@ -0,0 +1,176 @@
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
+ }