@rebasepro/server-postgres 0.0.1-canary.4829d6e
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 +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +640 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { eq, SQL } from "drizzle-orm";
|
|
2
|
+
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
+
import { CollectionConfig, Properties, Property, Relation, RelationProperty, Vector, BinaryProperty } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
6
|
+
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
7
|
+
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
8
|
+
import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
|
|
9
|
+
import { logger } from "@rebasepro/server";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Data transformation utilities for converting between frontend and database formats.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Typed result from `serializeDataToServer`.
|
|
17
|
+
* Replaces the hidden `__inverseRelationUpdates` / `__joinPathRelationUpdates`
|
|
18
|
+
* dunder-property mutation pattern with explicit, typed state management.
|
|
19
|
+
*/
|
|
20
|
+
export interface SerializedEntityData {
|
|
21
|
+
/** Scalar column values ready for INSERT/UPDATE. */
|
|
22
|
+
scalarData: Record<string, unknown>;
|
|
23
|
+
/** Inverse relation updates that must be applied to target tables. */
|
|
24
|
+
inverseRelationUpdates: Array<{
|
|
25
|
+
relationKey: string;
|
|
26
|
+
relation: Relation;
|
|
27
|
+
newValue: unknown;
|
|
28
|
+
currentId?: string | number;
|
|
29
|
+
}>;
|
|
30
|
+
/** JoinPath relation updates that require multi-hop writes. */
|
|
31
|
+
joinPathRelationUpdates: Array<{
|
|
32
|
+
relationKey: string;
|
|
33
|
+
relation: Relation;
|
|
34
|
+
newTargetId: string | number | null;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Helper function to sanitize and convert dates to ISO strings
|
|
39
|
+
*/
|
|
40
|
+
export function sanitizeAndConvertDates(obj: unknown): unknown {
|
|
41
|
+
if (obj === null || obj === undefined) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof obj === "number" && isNaN(obj)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof obj === "string" && obj.toLowerCase() === "nan") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(obj)) {
|
|
54
|
+
return obj.map(v => sanitizeAndConvertDates(v));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (obj instanceof Date) {
|
|
58
|
+
return obj.toISOString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof obj === "object") {
|
|
62
|
+
const newObj: Record<string, unknown> = {};
|
|
63
|
+
for (const key in obj) {
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
65
|
+
newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return newObj;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof obj === "string") {
|
|
72
|
+
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
73
|
+
const jsDateRegex = /^\w{3} \w{3} \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4} \(.+\)$/;
|
|
74
|
+
if (isoDateRegex.test(obj) || jsDateRegex.test(obj)) {
|
|
75
|
+
const date = new Date(obj);
|
|
76
|
+
if (!isNaN(date.getTime())) {
|
|
77
|
+
return date.toISOString();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return obj;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Transform relations for database storage (relation objects to IDs)
|
|
87
|
+
*/
|
|
88
|
+
export function serializeDataToServer<M extends Record<string, unknown>>(
|
|
89
|
+
row: M,
|
|
90
|
+
properties: Properties,
|
|
91
|
+
collection?: CollectionConfig,
|
|
92
|
+
registry?: PostgresCollectionRegistry
|
|
93
|
+
): SerializedEntityData {
|
|
94
|
+
if (!row || !properties) return { scalarData: row ?? {},
|
|
95
|
+
inverseRelationUpdates: [],
|
|
96
|
+
joinPathRelationUpdates: [] };
|
|
97
|
+
|
|
98
|
+
const result: Record<string, unknown> = {};
|
|
99
|
+
|
|
100
|
+
// Get normalized relations if collection is provided
|
|
101
|
+
const resolvedRelations = collection ? resolveCollectionRelations(collection) : {};
|
|
102
|
+
|
|
103
|
+
// Track inverse relations that need to be handled separately
|
|
104
|
+
const inverseRelationUpdates: Array<{
|
|
105
|
+
relationKey: string;
|
|
106
|
+
relation: Relation;
|
|
107
|
+
newValue: unknown;
|
|
108
|
+
currentId?: string | number;
|
|
109
|
+
}> = [];
|
|
110
|
+
const joinPathRelationUpdates: Array<{
|
|
111
|
+
relationKey: string;
|
|
112
|
+
relation: Relation;
|
|
113
|
+
newTargetId: string | number | null;
|
|
114
|
+
}> = [];
|
|
115
|
+
|
|
116
|
+
// Pre-calculate all local keys used as foreign keys
|
|
117
|
+
const foreignKeys = new Set<string>();
|
|
118
|
+
Object.values(resolvedRelations).forEach(relation => {
|
|
119
|
+
if (relation.localKey) foreignKeys.add(relation.localKey);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
for (const [key, value] of Object.entries(row)) {
|
|
123
|
+
const property = properties[key as keyof M] as Property;
|
|
124
|
+
|
|
125
|
+
// Coerce empty strings to null for any field that acts as a foreign key
|
|
126
|
+
const effectiveValue = (foreignKeys.has(key) && value === "") ? null : value;
|
|
127
|
+
|
|
128
|
+
if (!property) {
|
|
129
|
+
result[key] = effectiveValue;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
// Handle relation properties specially
|
|
135
|
+
if (property.type === "relation" && collection) {
|
|
136
|
+
const relation = findRelation(resolvedRelations, key);
|
|
137
|
+
if (relation) {
|
|
138
|
+
if (relation.direction === "owning" && relation.localKey) {
|
|
139
|
+
// Owning relation: Map relation object to FK column on current table
|
|
140
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
141
|
+
if (serializedValue !== undefined) {
|
|
142
|
+
result[relation.localKey] = serializedValue;
|
|
143
|
+
}
|
|
144
|
+
// Don't add the original relation property to the result
|
|
145
|
+
continue;
|
|
146
|
+
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
147
|
+
// Inverse relation: Need to update the target table's FK
|
|
148
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
149
|
+
const pks = getPrimaryKeys(collection, registry!);
|
|
150
|
+
inverseRelationUpdates.push({
|
|
151
|
+
relationKey: key,
|
|
152
|
+
relation,
|
|
153
|
+
newValue: serializedValue,
|
|
154
|
+
currentId: (row.id as string | number | undefined) || buildCompositeId(row, pks)
|
|
155
|
+
});
|
|
156
|
+
// Don't add the original relation property to the result
|
|
157
|
+
continue;
|
|
158
|
+
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
159
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
160
|
+
if (relation.cardinality === "one") {
|
|
161
|
+
// One-to-one inverse joinPath: route through joinPathRelationUpdates.
|
|
162
|
+
// The write ordering in PersistService ensures these are processed
|
|
163
|
+
// BEFORE the main UPDATE, so parentSourceCol reads the pre-update FK value.
|
|
164
|
+
// This prevents stale values from corrupting related rows when an
|
|
165
|
+
// intermediate FK (e.g. author_id) changes in the same save.
|
|
166
|
+
joinPathRelationUpdates.push({
|
|
167
|
+
relationKey: key,
|
|
168
|
+
relation,
|
|
169
|
+
newTargetId: serializedValue as string | number | null
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
// Many inverse joinPath: capture as inverse relation update
|
|
173
|
+
const pks = getPrimaryKeys(collection, registry!);
|
|
174
|
+
inverseRelationUpdates.push({
|
|
175
|
+
relationKey: key,
|
|
176
|
+
relation,
|
|
177
|
+
newValue: serializedValue,
|
|
178
|
+
currentId: (row.id as string | number | undefined) || buildCompositeId(row, pks)
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Don't add the original relation property to the result
|
|
182
|
+
continue;
|
|
183
|
+
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
184
|
+
// Owning one-to-one via joinPath: capture as a write intent
|
|
185
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
186
|
+
joinPathRelationUpdates.push({
|
|
187
|
+
relationKey: key,
|
|
188
|
+
relation,
|
|
189
|
+
newTargetId: serializedValue as string | number | null
|
|
190
|
+
});
|
|
191
|
+
// Don't include this property directly in payload
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
scalarData: result,
|
|
202
|
+
inverseRelationUpdates,
|
|
203
|
+
joinPathRelationUpdates
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Serialize a single property value for database storage
|
|
209
|
+
*/
|
|
210
|
+
export function serializePropertyToServer(value: unknown, property: Property): unknown {
|
|
211
|
+
if (value === null || value === undefined) {
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const propertyType = property.type;
|
|
216
|
+
|
|
217
|
+
switch (propertyType) {
|
|
218
|
+
case "relation":
|
|
219
|
+
if (Array.isArray(value)) {
|
|
220
|
+
return value.map(v => serializePropertyToServer(v, property));
|
|
221
|
+
} else if (typeof value === "object" && value !== null && "id" in value) {
|
|
222
|
+
return (value as Record<string, unknown>).id;
|
|
223
|
+
}
|
|
224
|
+
if (value === "") return null;
|
|
225
|
+
return value;
|
|
226
|
+
|
|
227
|
+
case "array":
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
if (property.of) {
|
|
230
|
+
return value.map(item => serializePropertyToServer(item, property.of as Property));
|
|
231
|
+
} else if (property.oneOf) {
|
|
232
|
+
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
233
|
+
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
234
|
+
return value.map((e) => {
|
|
235
|
+
if (e === null) return null;
|
|
236
|
+
if (typeof e !== "object") return e;
|
|
237
|
+
const rec = e as Record<string, unknown>;
|
|
238
|
+
const type = rec[typeField] as string;
|
|
239
|
+
const childProperty = property.oneOf?.properties[type];
|
|
240
|
+
if (!type || !childProperty) return e;
|
|
241
|
+
return {
|
|
242
|
+
[typeField]: type,
|
|
243
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
// Non-array value for an array property — coerce to avoid .map() crashes downstream
|
|
250
|
+
logger.warn(`Expected array value for array property, got ${typeof value}. Coercing to empty array.`);
|
|
251
|
+
return [];
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
case "map":
|
|
255
|
+
if (typeof value === "object" && property.properties) {
|
|
256
|
+
const result: Record<string, unknown> = {};
|
|
257
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
258
|
+
const subProperty = (property.properties as Properties)[subKey];
|
|
259
|
+
if (subProperty) {
|
|
260
|
+
result[subKey] = serializePropertyToServer(subValue, subProperty);
|
|
261
|
+
} else {
|
|
262
|
+
result[subKey] = subValue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
case "vector": {
|
|
269
|
+
if (value instanceof Vector) {
|
|
270
|
+
return value.value;
|
|
271
|
+
}
|
|
272
|
+
if (value && typeof value === "object" && "value" in value && Array.isArray((value as { value: unknown }).value)) {
|
|
273
|
+
return (value as { value: unknown[] }).value.map(Number);
|
|
274
|
+
}
|
|
275
|
+
if (Array.isArray(value)) {
|
|
276
|
+
return value.map(Number);
|
|
277
|
+
}
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "binary": {
|
|
282
|
+
const decoded = tryDecodeBase64DataUrl(value);
|
|
283
|
+
if (decoded) return decoded;
|
|
284
|
+
if (Buffer.isBuffer(value)) return value;
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
case "string":
|
|
289
|
+
default: {
|
|
290
|
+
const decoded = tryDecodeBase64DataUrl(value);
|
|
291
|
+
if (decoded) return decoded;
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Transform IDs back to relation objects for frontend
|
|
299
|
+
*/
|
|
300
|
+
export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
301
|
+
data: M,
|
|
302
|
+
collection: CollectionConfig,
|
|
303
|
+
db?: NodePgDatabase<Record<string, unknown>>,
|
|
304
|
+
registry?: PostgresCollectionRegistry
|
|
305
|
+
): Promise<M> {
|
|
306
|
+
const properties = collection.properties;
|
|
307
|
+
if (!data || !properties) return data;
|
|
308
|
+
|
|
309
|
+
// Get the normalized relations once
|
|
310
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
311
|
+
|
|
312
|
+
// Shared scalar + relation value normalization
|
|
313
|
+
const result = normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: false });
|
|
314
|
+
|
|
315
|
+
// Add relation properties that should be populated from FK values or inverse queries
|
|
316
|
+
for (const [propKey, property] of Object.entries(properties)) {
|
|
317
|
+
if (property.type === "relation" && !(propKey in result)) {
|
|
318
|
+
// Find the normalized relation for this property
|
|
319
|
+
const relation = findRelation(resolvedRelations, propKey);
|
|
320
|
+
if (relation) {
|
|
321
|
+
if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
|
|
322
|
+
// Owning relation: FK is in current table
|
|
323
|
+
const fkValue = data[relation.localKey as keyof M];
|
|
324
|
+
if (fkValue !== null && fkValue !== undefined) {
|
|
325
|
+
try {
|
|
326
|
+
const targetCollection = relation.target();
|
|
327
|
+
result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
|
|
328
|
+
} catch (e) {
|
|
329
|
+
logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) {
|
|
333
|
+
// Inverse relation: FK is in target table, need to query for it
|
|
334
|
+
try {
|
|
335
|
+
const targetCollection = relation.target();
|
|
336
|
+
const targetTable = registry.getTable(getTableName(targetCollection));
|
|
337
|
+
const pks = getPrimaryKeys(collection, registry!);
|
|
338
|
+
const currentId = buildCompositeId(data, pks);
|
|
339
|
+
|
|
340
|
+
if (targetTable && currentId) {
|
|
341
|
+
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
342
|
+
if (foreignKeyColumn) {
|
|
343
|
+
// Query the target table to find row that references this row
|
|
344
|
+
const relatedRows = await db
|
|
345
|
+
.select()
|
|
346
|
+
.from(targetTable)
|
|
347
|
+
.where(eq(foreignKeyColumn, currentId))
|
|
348
|
+
.limit(relation.cardinality === "one" ? 1 : 100); // Limit for one-to-one vs one-to-many
|
|
349
|
+
|
|
350
|
+
if (relatedRows.length > 0) {
|
|
351
|
+
if (relation.cardinality === "one") {
|
|
352
|
+
// One-to-one: return single relation object
|
|
353
|
+
const targetPks = getPrimaryKeys(targetCollection, registry!);
|
|
354
|
+
const relatedRow = relatedRows[0] as Record<string, unknown>;
|
|
355
|
+
result[propKey] = createRelationRef(buildCompositeId(relatedRow, targetPks), targetCollection.slug);
|
|
356
|
+
} else {
|
|
357
|
+
// One-to-many: return array of relation objects
|
|
358
|
+
const targetPks = getPrimaryKeys(targetCollection, registry!);
|
|
359
|
+
result[propKey] = relatedRows.map((row: Record<string, unknown>) =>
|
|
360
|
+
createRelationRef(buildCompositeId(row, targetPks), targetCollection.slug)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch (e) {
|
|
367
|
+
logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
|
|
368
|
+
}
|
|
369
|
+
} else if (relation.direction === "inverse" && relation.joinPath && db && registry) {
|
|
370
|
+
// Join path relation: Multi-hop relation using joins
|
|
371
|
+
try {
|
|
372
|
+
const targetCollection = relation.target();
|
|
373
|
+
const pks = getPrimaryKeys(collection, registry!);
|
|
374
|
+
const currentId = buildCompositeId(data, pks);
|
|
375
|
+
|
|
376
|
+
if (currentId) {
|
|
377
|
+
// Build the join query following the join path
|
|
378
|
+
const sourceTable = registry.getTable(getTableName(collection));
|
|
379
|
+
if (!sourceTable) {
|
|
380
|
+
logger.warn(`Source table not found for collection: ${collection.slug}`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let query = db.select().from(sourceTable);
|
|
385
|
+
let currentTable = sourceTable;
|
|
386
|
+
|
|
387
|
+
// Apply each join in the path
|
|
388
|
+
for (const join of relation.joinPath) {
|
|
389
|
+
const joinTable = registry.getTable(join.table);
|
|
390
|
+
if (!joinTable) {
|
|
391
|
+
logger.warn(`Join table not found: ${join.table}`);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Parse the join condition - handle both string and array formats
|
|
396
|
+
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
397
|
+
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
398
|
+
|
|
399
|
+
const fromParts = fromColumn.split(".");
|
|
400
|
+
const toParts = toColumn.split(".");
|
|
401
|
+
|
|
402
|
+
const fromColName = fromParts[fromParts.length - 1];
|
|
403
|
+
const toColName = toParts[toParts.length - 1];
|
|
404
|
+
|
|
405
|
+
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
406
|
+
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
407
|
+
|
|
408
|
+
if (!fromCol || !toCol) {
|
|
409
|
+
logger.warn(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
query = query.innerJoin(joinTable, eq(fromCol, toCol)) as unknown as typeof query;
|
|
414
|
+
currentTable = joinTable;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Add where condition for the current row
|
|
418
|
+
if (pks.length === 1) {
|
|
419
|
+
const sourceIdField = sourceTable[pks[0].fieldName as keyof typeof sourceTable] as AnyPgColumn;
|
|
420
|
+
query = query.where(eq(sourceIdField, currentId)) as typeof query;
|
|
421
|
+
} else {
|
|
422
|
+
// For composite keys, we would need to map the split parts. For now log a warning.
|
|
423
|
+
logger.warn(`Join path resolution for composite primary keys is not yet fully supported: ${collection.slug}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Build additional conditions array
|
|
427
|
+
const additionalFilters: SQL[] = [];
|
|
428
|
+
|
|
429
|
+
// Combine parent condition with additional filters using AND
|
|
430
|
+
let combinedWhere: SQL | undefined;
|
|
431
|
+
|
|
432
|
+
if (pks.length === 1) {
|
|
433
|
+
const sourceIdField = sourceTable[pks[0].fieldName as keyof typeof sourceTable] as AnyPgColumn;
|
|
434
|
+
combinedWhere = DrizzleConditionBuilder.combineConditionsWithAnd([
|
|
435
|
+
eq(sourceIdField, currentId),
|
|
436
|
+
...additionalFilters
|
|
437
|
+
].filter(Boolean) as SQL[]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Execute the query
|
|
441
|
+
const joinResults = await query.where(combinedWhere).limit(relation.cardinality === "one" ? 1 : 100);
|
|
442
|
+
|
|
443
|
+
if (joinResults.length > 0) {
|
|
444
|
+
const targetPks = getPrimaryKeys(targetCollection, registry!);
|
|
445
|
+
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
446
|
+
|
|
447
|
+
if (relation.cardinality === "one") {
|
|
448
|
+
// One-to-one: return single relation object
|
|
449
|
+
const joinResult = joinResults[0] as Record<string, unknown>;
|
|
450
|
+
const targetRow = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
|
|
451
|
+
result[propKey] = createRelationRef(buildCompositeId(targetRow, targetPks), targetCollection.slug);
|
|
452
|
+
} else {
|
|
453
|
+
// One-to-many: return array of relation objects
|
|
454
|
+
result[propKey] = joinResults.map((joinResult: Record<string, unknown>) => {
|
|
455
|
+
const targetRow = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
|
|
456
|
+
return createRelationRef(buildCompositeId(targetRow, targetPks), targetCollection.slug);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch (e) {
|
|
462
|
+
logger.warn(`Could not resolve join path relation property: ${propKey}`, { error: e });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return result as M;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Try to decode a `data:application/octet-stream;base64,...` data URL string
|
|
474
|
+
* into a Buffer. Returns null if the value is not a matching data URL.
|
|
475
|
+
*/
|
|
476
|
+
function tryDecodeBase64DataUrl(value: unknown): Buffer | null {
|
|
477
|
+
if (typeof value !== "string") return null;
|
|
478
|
+
if (!value.startsWith("data:application/octet-stream;base64,")) return null;
|
|
479
|
+
const base64Data = value.split(",")[1];
|
|
480
|
+
return base64Data ? Buffer.from(base64Data, "base64") : null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Try to resolve an unknown value into a Buffer.
|
|
485
|
+
* Handles native Buffers and `{ type: "Buffer", data: number[] }` objects (from JSON deserialization).
|
|
486
|
+
* Returns null if the value is not a buffer.
|
|
487
|
+
*/
|
|
488
|
+
function tryResolveBuffer(value: unknown): Buffer | null {
|
|
489
|
+
if (Buffer.isBuffer(value)) return value;
|
|
490
|
+
if (typeof value === "object" && value !== null) {
|
|
491
|
+
const rawVal = value as Record<string, unknown>;
|
|
492
|
+
if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
|
|
493
|
+
return Buffer.from(rawVal.data as number[]);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Convert a Buffer to a UTF-8 string if all bytes are printable ASCII,
|
|
501
|
+
* otherwise return a base64 data URL.
|
|
502
|
+
*/
|
|
503
|
+
function bufferToStringOrBase64(buf: Buffer): string {
|
|
504
|
+
for (let i = 0; i < buf.length; i++) {
|
|
505
|
+
const b = buf[i];
|
|
506
|
+
// Allow standard printable ASCII + common whitespace (\r, \n, \t)
|
|
507
|
+
if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
|
|
508
|
+
return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return buf.toString("utf8");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function parsePropertyFromServer(value: unknown, property: Property, collection: CollectionConfig, propertyKey?: string): unknown {
|
|
515
|
+
if (value === null || value === undefined) return value;
|
|
516
|
+
|
|
517
|
+
switch (property.type) {
|
|
518
|
+
case "binary": {
|
|
519
|
+
const buf = tryResolveBuffer(value);
|
|
520
|
+
if (buf) {
|
|
521
|
+
return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
case "string": {
|
|
527
|
+
if (typeof value === "string") return value;
|
|
528
|
+
|
|
529
|
+
// Handle Buffer objects (e.g. from PostgreSQL bytea columns)
|
|
530
|
+
const buf = tryResolveBuffer(value);
|
|
531
|
+
if (buf) {
|
|
532
|
+
return bufferToStringOrBase64(buf);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (typeof value === "object" && value !== null) {
|
|
536
|
+
try {
|
|
537
|
+
return JSON.stringify(value);
|
|
538
|
+
} catch {
|
|
539
|
+
return String(value);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return String(value);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
case "relation":
|
|
546
|
+
// Transform ID back to relation object with type information
|
|
547
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
548
|
+
let relationDef: Relation | undefined = (property as RelationProperty).relation;
|
|
549
|
+
if (!relationDef && propertyKey) {
|
|
550
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
551
|
+
relationDef = findRelation(resolvedRelations, propertyKey);
|
|
552
|
+
}
|
|
553
|
+
if (!relationDef) {
|
|
554
|
+
relationDef = collection.relations?.find((rel: Relation) => rel.relationName === (property as RelationProperty).relationName);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!relationDef) {
|
|
558
|
+
logger.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
559
|
+
return value;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const targetCollection = relationDef.target();
|
|
564
|
+
return createRelationRef(value.toString(), targetCollection.slug);
|
|
565
|
+
} catch (e) {
|
|
566
|
+
logger.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, { error: e });
|
|
567
|
+
return value;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return value;
|
|
571
|
+
|
|
572
|
+
case "array":
|
|
573
|
+
if (Array.isArray(value)) {
|
|
574
|
+
if (property.of) {
|
|
575
|
+
return value.map(item => parsePropertyFromServer(item, property.of as Property, collection));
|
|
576
|
+
} else if (property.oneOf) {
|
|
577
|
+
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
578
|
+
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
579
|
+
return value.map((e) => {
|
|
580
|
+
if (e === null) return null;
|
|
581
|
+
if (typeof e !== "object") return e;
|
|
582
|
+
const rec = e as Record<string, unknown>;
|
|
583
|
+
const type = rec[typeField] as string;
|
|
584
|
+
const childProperty = property.oneOf?.properties[type];
|
|
585
|
+
if (!type || !childProperty) return e;
|
|
586
|
+
return {
|
|
587
|
+
[typeField]: type,
|
|
588
|
+
[valueField]: parsePropertyFromServer(rec[valueField], childProperty, collection)
|
|
589
|
+
};
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
// Non-array value from DB for an array property — coerce to avoid .map() crashes
|
|
594
|
+
logger.warn(`Expected array value from DB for array property, got ${typeof value}. Coercing to array.`);
|
|
595
|
+
return typeof value === "string" ? [value] : [];
|
|
596
|
+
}
|
|
597
|
+
return value;
|
|
598
|
+
|
|
599
|
+
case "map":
|
|
600
|
+
if (typeof value === "object" && property.properties) {
|
|
601
|
+
const result: Record<string, unknown> = {};
|
|
602
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
603
|
+
const subProperty = (property.properties as Properties)[subKey];
|
|
604
|
+
if (subProperty) {
|
|
605
|
+
result[subKey] = parsePropertyFromServer(subValue, subProperty, collection);
|
|
606
|
+
} else {
|
|
607
|
+
result[subKey] = subValue;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
612
|
+
return value;
|
|
613
|
+
|
|
614
|
+
case "number":
|
|
615
|
+
if (typeof value === "string") {
|
|
616
|
+
const parsed = parseFloat(value);
|
|
617
|
+
return isNaN(parsed) ? null : parsed;
|
|
618
|
+
}
|
|
619
|
+
return value;
|
|
620
|
+
|
|
621
|
+
case "vector": {
|
|
622
|
+
let nums: number[] = [];
|
|
623
|
+
if (typeof value === "string") {
|
|
624
|
+
nums = value.slice(1, -1).split(",").map(Number);
|
|
625
|
+
} else if (Array.isArray(value)) {
|
|
626
|
+
nums = value.map(Number);
|
|
627
|
+
} else if (value instanceof Vector) {
|
|
628
|
+
nums = value.value;
|
|
629
|
+
} else if (typeof value === "object" && value !== null && "value" in value) {
|
|
630
|
+
const valObj = value as { value: unknown };
|
|
631
|
+
if (Array.isArray(valObj.value)) {
|
|
632
|
+
nums = valObj.value.map(Number);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
__type: "Vector",
|
|
637
|
+
value: nums
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
case "date": {
|
|
642
|
+
let date: Date | undefined;
|
|
643
|
+
if (value instanceof Date) {
|
|
644
|
+
date = value;
|
|
645
|
+
} else if (typeof value === "string" || typeof value === "number") {
|
|
646
|
+
const parsedDate = new Date(value);
|
|
647
|
+
if (!isNaN(parsedDate.getTime())) {
|
|
648
|
+
date = parsedDate;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (date) {
|
|
652
|
+
return {
|
|
653
|
+
__type: "date",
|
|
654
|
+
value: date.toISOString()
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
default: {
|
|
661
|
+
// Fallback for buffers in case they are mapped to something other than string
|
|
662
|
+
const buf = tryResolveBuffer(value);
|
|
663
|
+
if (buf) {
|
|
664
|
+
return bufferToStringOrBase64(buf);
|
|
665
|
+
}
|
|
666
|
+
return value;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Shared internal helper: normalizes scalar column values from a DB row
|
|
673
|
+
* into frontend format. Handles FK-column preservation, type coercion
|
|
674
|
+
* (dates, numbers, NaN), and property filtering.
|
|
675
|
+
*
|
|
676
|
+
* @param skipRelations When `true`, relation-typed properties are omitted
|
|
677
|
+
* from the result (used by `normalizeDbValues` where
|
|
678
|
+
* Drizzle's relational API already hydrates them).
|
|
679
|
+
*/
|
|
680
|
+
function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
681
|
+
data: M,
|
|
682
|
+
properties: Properties,
|
|
683
|
+
collection: CollectionConfig,
|
|
684
|
+
resolvedRelations: Record<string, Relation>,
|
|
685
|
+
options: { skipRelations: boolean }
|
|
686
|
+
): Record<string, unknown> {
|
|
687
|
+
const result: Record<string, unknown> = {};
|
|
688
|
+
|
|
689
|
+
// Identify FK columns used only for relations and not exposed as properties
|
|
690
|
+
const internalFKColumns = new Set<string>();
|
|
691
|
+
Object.values(resolvedRelations).forEach(relation => {
|
|
692
|
+
if (relation.localKey && !properties[relation.localKey]) {
|
|
693
|
+
internalFKColumns.add(relation.localKey);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
for (const [key, value] of Object.entries(data)) {
|
|
698
|
+
// Keep internal FK columns as primitives
|
|
699
|
+
if (internalFKColumns.has(key)) {
|
|
700
|
+
result[key] = value === null ? null : (typeof value === "number" ? value : String(value));
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const property = properties[key as keyof M] as Property;
|
|
705
|
+
if (!property) continue; // Skip DB columns not defined in properties
|
|
706
|
+
|
|
707
|
+
if (options.skipRelations && property.type === "relation") continue;
|
|
708
|
+
|
|
709
|
+
result[key] = parsePropertyFromServer(value, property, collection, key);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Lightweight value normalization for db.query results.
|
|
717
|
+
* Only handles type coercion (dates, numbers, NaN) and property filtering.
|
|
718
|
+
* Does NOT query the database for relations — those are already resolved
|
|
719
|
+
* by Drizzle's relational query API.
|
|
720
|
+
*
|
|
721
|
+
* Use this instead of `parseDataFromServer` when processing results from
|
|
722
|
+
* `db.query.findFirst/findMany` which return pre-hydrated relation data.
|
|
723
|
+
*/
|
|
724
|
+
export function normalizeDbValues<M extends Record<string, unknown>>(
|
|
725
|
+
data: M,
|
|
726
|
+
collection: CollectionConfig
|
|
727
|
+
): M {
|
|
728
|
+
const properties = collection.properties;
|
|
729
|
+
if (!data || !properties) return data;
|
|
730
|
+
|
|
731
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
732
|
+
return normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: true }) as M;
|
|
733
|
+
}
|