@rebasepro/server-postgres 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,845 +0,0 @@
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, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
6
- import { isPrototypePollutingKey } from "@rebasepro/utils";
7
- import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
8
- import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
9
- import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
10
- import { ApiError, logger } from "@rebasepro/server";
11
-
12
- /**
13
- * Data transformation utilities for converting between frontend and database formats.
14
- */
15
-
16
- /**
17
- * Typed result from `serializeDataToServer`.
18
- * Replaces the hidden `__inverseRelationUpdates` / `__joinPathRelationUpdates`
19
- * dunder-property mutation pattern with explicit, typed state management.
20
- */
21
- export interface SerializedEntityData {
22
- /** Scalar column values ready for INSERT/UPDATE. */
23
- scalarData: Record<string, unknown>;
24
- /**
25
- * Inverse relation updates that must be applied to target tables.
26
- *
27
- * No address here: the row being written does not know its own. These are
28
- * applied by `PersistService`, which addresses them with the id it holds —
29
- * the one it was given for an update, or the one the INSERT returned — and
30
- * that is the authority. This item used to carry a `currentId` derived from
31
- * the *input* values, which nothing ever read.
32
- */
33
- inverseRelationUpdates: Array<{
34
- relationKey: string;
35
- relation: ResolvedRelation;
36
- newValue: unknown;
37
- }>;
38
- /** JoinPath relation updates that require multi-hop writes. */
39
- joinPathRelationUpdates: Array<{
40
- relationKey: string;
41
- relation: ResolvedVia;
42
- newTargetId: string | number | null;
43
- }>;
44
- }
45
- /**
46
- * Helper function to sanitize and convert dates to ISO strings
47
- */
48
- export function sanitizeAndConvertDates(obj: unknown): unknown {
49
- if (obj === null || obj === undefined) {
50
- return null;
51
- }
52
-
53
- if (typeof obj === "number" && isNaN(obj)) {
54
- return null;
55
- }
56
-
57
- if (typeof obj === "string" && obj.toLowerCase() === "nan") {
58
- return null;
59
- }
60
-
61
- if (Array.isArray(obj)) {
62
- return obj.map(v => sanitizeAndConvertDates(v));
63
- }
64
-
65
- if (obj instanceof Date) {
66
- return obj.toISOString();
67
- }
68
-
69
- if (typeof obj === "object") {
70
- const newObj: Record<string, unknown> = {};
71
- for (const key in obj) {
72
- if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
73
- // `JSON.parse` creates `__proto__` as an *own* property, so it gets
74
- // past the check above — and `newObj[key] = …` then invokes the
75
- // prototype setter instead of creating a property. The row's
76
- // prototype becomes whatever the request body supplied, so
77
- // `row.isAdmin` answers `true` while `Object.keys(row)` shows
78
- // nothing of the sort. No column can be reached by these names.
79
- if (isPrototypePollutingKey(key)) continue;
80
- newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
81
- }
82
- return newObj;
83
- }
84
-
85
- if (typeof obj === "string") {
86
- const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
87
- const jsDateRegex = /^\w{3} \w{3} \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4} \(.+\)$/;
88
- if (isoDateRegex.test(obj) || jsDateRegex.test(obj)) {
89
- const date = new Date(obj);
90
- if (!isNaN(date.getTime())) {
91
- return date.toISOString();
92
- }
93
- }
94
- }
95
-
96
- return obj;
97
- }
98
-
99
- /**
100
- * Transform relations for database storage (relation objects to IDs)
101
- */
102
- export function serializeDataToServer<M extends Record<string, unknown>>(
103
- row: M,
104
- properties: Properties,
105
- collection?: CollectionConfig,
106
- registry?: PostgresCollectionRegistry
107
- ): SerializedEntityData {
108
- if (!row || !properties) return { scalarData: row ?? {},
109
- inverseRelationUpdates: [],
110
- joinPathRelationUpdates: [] };
111
-
112
- const result: Record<string, unknown> = {};
113
-
114
- // Get normalized relations if collection is provided
115
- const resolvedRelations = collection ? resolveCollectionRelations(collection) : {};
116
-
117
- // Track inverse relations that need to be handled separately
118
- const inverseRelationUpdates: Array<{
119
- relationKey: string;
120
- relation: ResolvedRelation;
121
- newValue: unknown;
122
- }> = [];
123
- const joinPathRelationUpdates: Array<{
124
- relationKey: string;
125
- relation: ResolvedVia;
126
- newTargetId: string | number | null;
127
- }> = [];
128
-
129
- // Pre-calculate all local keys used as foreign keys.
130
- //
131
- // Keyed by the *wire* name: the payload arrives from a client, which knows
132
- // the field as `authorId`, never as the `author_id` column behind it.
133
- const foreignKeys = new Set<string>();
134
- Object.values(resolvedRelations).forEach(relation => {
135
- if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
136
- });
137
-
138
- for (const [key, value] of Object.entries(row)) {
139
- // Same reasoning as `sanitizeAndConvertDates`: these keys come from the
140
- // request body, and no column answers to them.
141
- if (isPrototypePollutingKey(key)) continue;
142
-
143
- // `{ subtitle: undefined }` means "I have no value for this", not "set
144
- // this column to NULL" — it is what spreading an optional field
145
- // produces, and what Drizzle itself skips. The key used to survive with
146
- // `undefined` and `sanitizeAndConvertDates` then turned it into `null`,
147
- // so `update(id, { title, subtitle: payload.subtitle })` wiped
148
- // `subtitle` whenever the payload happened not to carry one. Unreachable
149
- // over HTTP (JSON has no `undefined`); the in-process data API passes
150
- // the caller's object straight through, so it is reachable there.
151
- if (value === undefined) continue;
152
-
153
- const property = properties[key as keyof M] as Property;
154
-
155
- // Coerce empty strings to null for any field that acts as a foreign key
156
- const effectiveValue = (foreignKeys.has(key) && value === "") ? null : value;
157
-
158
- if (!property) {
159
- result[key] = effectiveValue;
160
- continue;
161
- }
162
-
163
-
164
- // Handle relation properties specially
165
- if (property.type === "relation" && collection) {
166
- const relation = findRelation(resolvedRelations, key);
167
- if (relation) {
168
- if (relation.kind === "belongsTo") {
169
- // Owning relation: Map relation object to FK column on current table
170
- const serializedValue = serializePropertyToServer(effectiveValue, property, key);
171
- if (serializedValue !== undefined) {
172
- // The Drizzle key, not the column: Drizzle maps it back
173
- // to `author_id` when it builds the statement.
174
- result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
175
- }
176
- // Don't add the original relation property to the result
177
- continue;
178
- } else if (hasForeignKeyOnTarget(relation)) {
179
- // Inverse relation: Need to update the target table's FK
180
- const serializedValue = serializePropertyToServer(effectiveValue, property, key);
181
- inverseRelationUpdates.push({
182
- relationKey: key,
183
- relation,
184
- newValue: serializedValue
185
- });
186
- // Don't add the original relation property to the result
187
- continue;
188
- } else if (relation.kind === "via") {
189
- // A join chain is written through its own machinery, one
190
- // row at a time for a to-one and as a set for a to-many.
191
- // There used to be two arms here — "owning" and "inverse"
192
- // joinPath — but a join chain has no owning side, so they
193
- // only ever differed by which list they pushed to.
194
- const serializedValue = serializePropertyToServer(effectiveValue, property, key);
195
- if (relation.cardinality === "one") {
196
- // Ordering matters: PersistService applies these BEFORE
197
- // the main UPDATE, so the mapping reads the pre-update
198
- // foreign key rather than a value the same save changed.
199
- joinPathRelationUpdates.push({
200
- relationKey: key,
201
- relation,
202
- newTargetId: serializedValue as string | number | null
203
- });
204
- } else {
205
- inverseRelationUpdates.push({
206
- relationKey: key,
207
- relation,
208
- newValue: serializedValue
209
- });
210
- }
211
- continue;
212
- }
213
- }
214
- }
215
-
216
- result[key] = serializePropertyToServer(effectiveValue, property, key);
217
- }
218
-
219
- return {
220
- scalarData: result,
221
- inverseRelationUpdates,
222
- joinPathRelationUpdates
223
- };
224
- }
225
-
226
- /**
227
- * How to name a rejected value in an error, without quoting it back.
228
- *
229
- * The value may be anything the caller sent, including a secret in the wrong
230
- * field, so the message describes its shape rather than echoing it — an echoed
231
- * value ends up in logs and in error-reporting services.
232
- */
233
- function describeValue(value: unknown): string {
234
- if (value === null) return "null";
235
- if (Array.isArray(value)) return "an array";
236
- if (value instanceof Date) return "a date";
237
- const type = typeof value;
238
- return type === "object" ? "an object" : `a ${type}`;
239
- }
240
-
241
- /**
242
- * Serialize a single property value for database storage.
243
- *
244
- * `propertyKey` is only ever used to phrase errors and warnings. Without it the
245
- * one trace a bad value left was `Expected array value for array property, got
246
- * string` — no collection, no property, no value, which in the log of a
247
- * thousand-row import names nothing at all.
248
- */
249
- export function serializePropertyToServer(value: unknown, property: Property, propertyKey?: string): unknown {
250
- if (value === null || value === undefined) {
251
- return value;
252
- }
253
-
254
- const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
255
-
256
- const propertyType = property.type;
257
-
258
- switch (propertyType) {
259
- case "relation":
260
- if (Array.isArray(value)) {
261
- return value.map(v => serializePropertyToServer(v, property, propertyKey));
262
- } else if (typeof value === "object" && value !== null && "id" in value) {
263
- return (value as Record<string, unknown>).id;
264
- }
265
- if (value === "") return null;
266
- return value;
267
-
268
- case "array":
269
- if (Array.isArray(value)) {
270
- if (property.of) {
271
- return value.map(item => serializePropertyToServer(item, property.of as Property, propertyKey));
272
- } else if (property.oneOf) {
273
- const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
274
- const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
275
- return value.map((e) => {
276
- if (e === null) return null;
277
- if (typeof e !== "object") return e;
278
- const rec = e as Record<string, unknown>;
279
- const type = rec[typeField] as string;
280
- const childProperty = property.oneOf?.properties[type];
281
- if (!type || !childProperty) return e;
282
- return {
283
- [typeField]: type,
284
- [valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
285
- };
286
- });
287
- }
288
- return value;
289
- }
290
- // A non-array value used to become `[]` here — the caller's value
291
- // destroyed, answered 200/201, and the row reading back as an empty
292
- // list on every later fetch. `POST /posts {"tags":"news"}` from a
293
- // client that sent a single tag as a scalar, or a CSV import that
294
- // did not split a column, was unrecoverable data loss on a `text[]`
295
- // column. The stated reason for coercing — "avoid .map() crashes
296
- // downstream" — is better served by refusing the value at the
297
- // boundary, which is what a 400 does.
298
- //
299
- // The read-side twin (`parsePropertyFromServer`) still coerces, and
300
- // should: a row already in the database is not this caller's fault.
301
- throw ApiError.badRequest(
302
- `${fieldLabel} expects an array, but received ${describeValue(value)}.`,
303
- "VALIDATION_INVALID_VALUE"
304
- );
305
-
306
- case "geopoint": {
307
- // Stored as `jsonb`, in the `{ latitude, longitude }` shape the
308
- // OpenAPI schema and the generated TS type both promise. Nothing
309
- // used to handle `geopoint` on either side, which is why the type
310
- // was documented, code-generated, admin-editable — and dropped.
311
- if (typeof value !== "object" || Array.isArray(value)) {
312
- throw ApiError.badRequest(
313
- `${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, ` +
314
- `but received ${describeValue(value)}.`,
315
- "VALIDATION_INVALID_VALUE"
316
- );
317
- }
318
- const point = value as Record<string, unknown>;
319
- if (typeof point.latitude !== "number" || typeof point.longitude !== "number") {
320
- throw ApiError.badRequest(
321
- `${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`,
322
- "VALIDATION_INVALID_VALUE"
323
- );
324
- }
325
- return { latitude: point.latitude,
326
- longitude: point.longitude };
327
- }
328
-
329
-
330
- case "map":
331
- if (typeof value === "object" && property.properties) {
332
- const result: Record<string, unknown> = {};
333
- for (const [subKey, subValue] of Object.entries(value)) {
334
- const subProperty = (property.properties as Properties)[subKey];
335
- if (subProperty) {
336
- result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
337
- } else {
338
- result[subKey] = subValue;
339
- }
340
- }
341
- return result;
342
- }
343
- return value;
344
- case "vector": {
345
- if (value instanceof Vector) {
346
- return value.value;
347
- }
348
- if (value && typeof value === "object" && "value" in value && Array.isArray((value as { value: unknown }).value)) {
349
- return (value as { value: unknown[] }).value.map(Number);
350
- }
351
- if (Array.isArray(value)) {
352
- return value.map(Number);
353
- }
354
- return value;
355
- }
356
-
357
- case "binary": {
358
- const decoded = tryDecodeBase64DataUrl(value);
359
- if (decoded) return decoded;
360
- if (Buffer.isBuffer(value)) return value;
361
- return value;
362
- }
363
-
364
- case "string":
365
- default: {
366
- const decoded = tryDecodeBase64DataUrl(value);
367
- if (decoded) return decoded;
368
- return value;
369
- }
370
- }
371
- }
372
-
373
- /**
374
- * Transform IDs back to relation objects for frontend
375
- */
376
- export async function parseDataFromServer<M extends Record<string, unknown>>(
377
- data: M,
378
- collection: CollectionConfig,
379
- db?: NodePgDatabase<Record<string, unknown>>,
380
- registry?: PostgresCollectionRegistry
381
- ): Promise<M> {
382
- const properties = collection.properties;
383
- if (!data || !properties) return data;
384
-
385
- // Get the normalized relations once
386
- const resolvedRelations = resolveCollectionRelations(collection);
387
-
388
- // Shared scalar + relation value normalization
389
- const result = normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: false });
390
-
391
- // Add relation properties that should be populated from FK values or inverse queries
392
- for (const [propKey, property] of Object.entries(properties)) {
393
- if (property.type === "relation" && !(propKey in result)) {
394
- // Find the normalized relation for this property
395
- const relation = findRelation(resolvedRelations, propKey);
396
- if (relation) {
397
- const localField = relation.kind === "belongsTo"
398
- ? fieldKeyForColumn(collection, relation.localKey)
399
- : "";
400
- if (relation.kind === "belongsTo" && localField in data) {
401
- // Owning relation: FK is in current table, under its wire name
402
- const fkValue = data[localField as keyof M];
403
- if (fkValue !== null && fkValue !== undefined) {
404
- try {
405
- const targetCollection = relation.target();
406
- result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
407
- } catch (e) {
408
- logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
409
- }
410
- }
411
- } else if (hasForeignKeyOnTarget(relation) && db && registry) {
412
- // Inverse relation: FK is in target table, need to query for it
413
- try {
414
- const targetCollection = relation.target();
415
- const targetTable = registry.getTable(getTableName(targetCollection));
416
- const pks = getPrimaryKeys(collection, registry!);
417
- // What the target's foreign key holds. Ordinarily this
418
- // row's id; for a link on a natural key, the value of
419
- // the column it points at — which is on the row already,
420
- // so this costs nothing extra.
421
- const currentId = relation.sourceKey
422
- ? (data as Record<string, unknown>)[relation.sourceKey] as string | number | undefined
423
- : buildCompositeId(data, pks);
424
-
425
- if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
426
- const fkFieldKey = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
427
- const foreignKeyColumn = targetTable[fkFieldKey as keyof typeof targetTable] as AnyPgColumn;
428
- if (foreignKeyColumn) {
429
- // Query the target table to find row that references this row
430
- const relatedRows = await db
431
- .select()
432
- .from(targetTable)
433
- .where(eq(foreignKeyColumn, currentId))
434
- .limit(relation.cardinality === "one" ? 1 : 100); // Limit for one-to-one vs one-to-many
435
-
436
- if (relatedRows.length > 0) {
437
- if (relation.cardinality === "one") {
438
- // One-to-one: return single relation object
439
- const targetPks = getPrimaryKeys(targetCollection, registry!);
440
- const relatedRow = relatedRows[0] as Record<string, unknown>;
441
- result[propKey] = createRelationRef(buildCompositeId(relatedRow, targetPks), targetCollection.slug);
442
- } else {
443
- // One-to-many: return array of relation objects
444
- const targetPks = getPrimaryKeys(targetCollection, registry!);
445
- result[propKey] = relatedRows.map((row: Record<string, unknown>) =>
446
- createRelationRef(buildCompositeId(row, targetPks), targetCollection.slug)
447
- );
448
- }
449
- }
450
- }
451
- }
452
- } catch (e) {
453
- logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
454
- }
455
- } else if (relation.kind === "via" && db && registry) {
456
- // Join path relation: Multi-hop relation using joins
457
- try {
458
- const targetCollection = relation.target();
459
- const pks = getPrimaryKeys(collection, registry!);
460
- const currentId = buildCompositeId(data, pks);
461
-
462
- if (currentId) {
463
- // Build the join query following the join path
464
- const sourceTable = registry.getTable(getTableName(collection));
465
- if (!sourceTable) {
466
- logger.warn(`Source table not found for collection: ${collection.slug}`);
467
- continue;
468
- }
469
-
470
- let query = db.select().from(sourceTable);
471
- let currentTable = sourceTable;
472
-
473
- // Apply each join in the path
474
- for (const join of relation.joinPath) {
475
- const joinTable = registry.getTable(join.table);
476
- if (!joinTable) {
477
- logger.warn(`Join table not found: ${join.table}`);
478
- break;
479
- }
480
-
481
- // Parse the join condition - handle both string and array formats
482
- const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
483
- const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
484
-
485
- const fromParts = fromColumn.split(".");
486
- const toParts = toColumn.split(".");
487
-
488
- const fromColName = fromParts[fromParts.length - 1];
489
- const toColName = toParts[toParts.length - 1];
490
-
491
- const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
492
- const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
493
-
494
- if (!fromCol || !toCol) {
495
- logger.warn(`Join columns not found: ${fromColumn} -> ${toColumn}`);
496
- break;
497
- }
498
-
499
- query = query.innerJoin(joinTable, eq(fromCol, toCol)) as unknown as typeof query;
500
- currentTable = joinTable;
501
- }
502
-
503
- // Add where condition for the current row
504
- if (pks.length === 1) {
505
- const sourceIdField = sourceTable[pks[0].fieldName as keyof typeof sourceTable] as AnyPgColumn;
506
- query = query.where(eq(sourceIdField, currentId)) as typeof query;
507
- } else {
508
- // For composite keys, we would need to map the split parts. For now log a warning.
509
- logger.warn(`Join path resolution for composite primary keys is not yet fully supported: ${collection.slug}`);
510
- }
511
-
512
- // Build additional conditions array
513
- const additionalFilters: SQL[] = [];
514
-
515
- // Combine parent condition with additional filters using AND
516
- let combinedWhere: SQL | undefined;
517
-
518
- if (pks.length === 1) {
519
- const sourceIdField = sourceTable[pks[0].fieldName as keyof typeof sourceTable] as AnyPgColumn;
520
- combinedWhere = DrizzleConditionBuilder.combineConditionsWithAnd([
521
- eq(sourceIdField, currentId),
522
- ...additionalFilters
523
- ].filter(Boolean) as SQL[]);
524
- }
525
-
526
- // Execute the query
527
- const joinResults = await query.where(combinedWhere).limit(relation.cardinality === "one" ? 1 : 100);
528
-
529
- if (joinResults.length > 0) {
530
- const targetPks = getPrimaryKeys(targetCollection, registry!);
531
- const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
532
-
533
- if (relation.cardinality === "one") {
534
- // One-to-one: return single relation object
535
- const joinResult = joinResults[0] as Record<string, unknown>;
536
- const targetRow = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
537
- result[propKey] = createRelationRef(buildCompositeId(targetRow, targetPks), targetCollection.slug);
538
- } else {
539
- // One-to-many: return array of relation objects
540
- result[propKey] = joinResults.map((joinResult: Record<string, unknown>) => {
541
- const targetRow = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
542
- return createRelationRef(buildCompositeId(targetRow, targetPks), targetCollection.slug);
543
- });
544
- }
545
- }
546
- }
547
- } catch (e) {
548
- logger.warn(`Could not resolve join path relation property: ${propKey}`, { error: e });
549
- }
550
- }
551
- }
552
- }
553
- }
554
-
555
- return result as M;
556
- }
557
-
558
- /**
559
- * Try to decode a `data:application/octet-stream;base64,...` data URL string
560
- * into a Buffer. Returns null if the value is not a matching data URL.
561
- */
562
- function tryDecodeBase64DataUrl(value: unknown): Buffer | null {
563
- if (typeof value !== "string") return null;
564
- if (!value.startsWith("data:application/octet-stream;base64,")) return null;
565
- const base64Data = value.split(",")[1];
566
- return base64Data ? Buffer.from(base64Data, "base64") : null;
567
- }
568
-
569
- /**
570
- * Try to resolve an unknown value into a Buffer.
571
- * Handles native Buffers and `{ type: "Buffer", data: number[] }` objects (from JSON deserialization).
572
- * Returns null if the value is not a buffer.
573
- */
574
- function tryResolveBuffer(value: unknown): Buffer | null {
575
- if (Buffer.isBuffer(value)) return value;
576
- if (typeof value === "object" && value !== null) {
577
- const rawVal = value as Record<string, unknown>;
578
- if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
579
- return Buffer.from(rawVal.data as number[]);
580
- }
581
- }
582
- return null;
583
- }
584
-
585
- /**
586
- * Convert a Buffer to a UTF-8 string if all bytes are printable ASCII,
587
- * otherwise return a base64 data URL.
588
- */
589
- function bufferToStringOrBase64(buf: Buffer): string {
590
- for (let i = 0; i < buf.length; i++) {
591
- const b = buf[i];
592
- // Allow standard printable ASCII + common whitespace (\r, \n, \t)
593
- if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
594
- return `data:application/octet-stream;base64,${buf.toString("base64")}`;
595
- }
596
- }
597
- return buf.toString("utf8");
598
- }
599
-
600
- export function parsePropertyFromServer(value: unknown, property: Property, collection: CollectionConfig, propertyKey?: string): unknown {
601
- if (value === null || value === undefined) return value;
602
-
603
- switch (property.type) {
604
- case "binary": {
605
- const buf = tryResolveBuffer(value);
606
- if (buf) {
607
- return `data:application/octet-stream;base64,${buf.toString("base64")}`;
608
- }
609
- return value;
610
- }
611
-
612
- case "string": {
613
- if (typeof value === "string") return value;
614
-
615
- // Handle Buffer objects (e.g. from PostgreSQL bytea columns)
616
- const buf = tryResolveBuffer(value);
617
- if (buf) {
618
- return bufferToStringOrBase64(buf);
619
- }
620
-
621
- if (typeof value === "object" && value !== null) {
622
- try {
623
- return JSON.stringify(value);
624
- } catch {
625
- return String(value);
626
- }
627
- }
628
- return String(value);
629
- }
630
-
631
- case "relation":
632
- // Transform ID back to relation object with type information
633
- if (typeof value === "string" || typeof value === "number") {
634
- // Normalization stamps the resolved relation onto the property;
635
- // fall back to the collection's map for a property that never
636
- // went through the registry.
637
- let relationDef: ResolvedRelation | undefined = (property as RelationProperty).resolvedRelation;
638
- if (!relationDef && propertyKey) {
639
- relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
640
- }
641
-
642
- if (!relationDef) {
643
- logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
644
- return value;
645
- }
646
-
647
- try {
648
- const targetCollection = relationDef.target();
649
- return createRelationRef(value.toString(), targetCollection.slug);
650
- } catch (e) {
651
- logger.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, { error: e });
652
- return value;
653
- }
654
- }
655
- return value;
656
-
657
- case "array":
658
- if (Array.isArray(value)) {
659
- if (property.of) {
660
- return value.map(item => parsePropertyFromServer(item, property.of as Property, collection));
661
- } else if (property.oneOf) {
662
- const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
663
- const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
664
- return value.map((e) => {
665
- if (e === null) return null;
666
- if (typeof e !== "object") return e;
667
- const rec = e as Record<string, unknown>;
668
- const type = rec[typeField] as string;
669
- const childProperty = property.oneOf?.properties[type];
670
- if (!type || !childProperty) return e;
671
- return {
672
- [typeField]: type,
673
- [valueField]: parsePropertyFromServer(rec[valueField], childProperty, collection)
674
- };
675
- });
676
- }
677
- } else {
678
- // Non-array value from DB for an array property — coerce to avoid .map() crashes
679
- logger.warn(`Expected array value from DB for array property, got ${typeof value}. Coercing to array.`);
680
- return typeof value === "string" ? [value] : [];
681
- }
682
- return value;
683
-
684
- case "map":
685
- if (typeof value === "object" && property.properties) {
686
- const result: Record<string, unknown> = {};
687
- for (const [subKey, subValue] of Object.entries(value)) {
688
- const subProperty = (property.properties as Properties)[subKey];
689
- if (subProperty) {
690
- result[subKey] = parsePropertyFromServer(subValue, subProperty, collection);
691
- } else {
692
- result[subKey] = subValue;
693
- }
694
- }
695
- return result;
696
- }
697
- return value;
698
-
699
- case "number":
700
- if (typeof value === "string") {
701
- const parsed = parseFloat(value);
702
- return isNaN(parsed) ? null : parsed;
703
- }
704
- return value;
705
-
706
- case "geopoint":
707
- // A `jsonb` column, so node-postgres hands back the object already.
708
- // Named explicitly rather than left to `default:`, which would run
709
- // it past the Buffer probe — and because a type with a write
710
- // serializer and no read counterpart is how this one went missing.
711
- return value;
712
-
713
- case "vector": {
714
- let nums: number[] = [];
715
- if (typeof value === "string") {
716
- nums = value.slice(1, -1).split(",").map(Number);
717
- } else if (Array.isArray(value)) {
718
- nums = value.map(Number);
719
- } else if (value instanceof Vector) {
720
- nums = value.value;
721
- } else if (typeof value === "object" && value !== null && "value" in value) {
722
- const valObj = value as { value: unknown };
723
- if (Array.isArray(valObj.value)) {
724
- nums = valObj.value.map(Number);
725
- }
726
- }
727
- return {
728
- __type: "Vector",
729
- value: nums
730
- };
731
- }
732
-
733
- case "date": {
734
- let date: Date | undefined;
735
- if (value instanceof Date) {
736
- date = value;
737
- } else if (typeof value === "string" || typeof value === "number") {
738
- const parsedDate = new Date(value);
739
- if (!isNaN(parsedDate.getTime())) {
740
- date = parsedDate;
741
- }
742
- }
743
- if (date) {
744
- return {
745
- __type: "date",
746
- value: date.toISOString()
747
- };
748
- }
749
- return null;
750
- }
751
-
752
- default: {
753
- // Fallback for buffers in case they are mapped to something other than string
754
- const buf = tryResolveBuffer(value);
755
- if (buf) {
756
- return bufferToStringOrBase64(buf);
757
- }
758
- return value;
759
- }
760
- }
761
- }
762
-
763
- /**
764
- * Shared internal helper: normalizes scalar column values from a DB row
765
- * into frontend format. Handles FK-column preservation, type coercion
766
- * (dates, numbers, NaN), and property filtering.
767
- *
768
- * @param skipRelations When `true`, relation-typed properties are omitted
769
- * from the result (used by `normalizeDbValues` where
770
- * Drizzle's relational API already hydrates them).
771
- */
772
- /**
773
- * Keys a query computes and attaches to a row, rather than reads from a column.
774
- *
775
- * An explicit set rather than a `_` prefix rule: a user's column may perfectly
776
- * well be called `_internal`, and passing it through here would put a value on
777
- * the row that no property describes and nothing downstream knows how to type.
778
- */
779
- const QUERY_METADATA_KEYS = new Set(["_score", "_distance", "_matches"]);
780
-
781
- function normalizeScalarValues<M extends Record<string, unknown>>(
782
- data: M,
783
- properties: Properties,
784
- collection: CollectionConfig,
785
- resolvedRelations: Record<string, ResolvedRelation>,
786
- options: { skipRelations: boolean }
787
- ): Record<string, unknown> {
788
- const result: Record<string, unknown> = {};
789
-
790
- // Identify FK columns used only for relations and not exposed as properties
791
- const internalFKColumns = new Set<string>();
792
- Object.values(resolvedRelations).forEach(relation => {
793
- if (relation.kind !== "belongsTo") return;
794
- // The key a row carries this foreign key under, which is the wire name
795
- // — `authorId`, not the `author_id` column.
796
- const localField = fieldKeyForColumn(collection, relation.localKey);
797
- if (!properties[localField]) internalFKColumns.add(localField);
798
- });
799
-
800
- for (const [key, value] of Object.entries(data)) {
801
- // Keep internal FK columns as primitives
802
- if (internalFKColumns.has(key)) {
803
- result[key] = value === null ? null : (typeof value === "number" ? value : String(value));
804
- continue;
805
- }
806
-
807
- // Query-computed metadata, which by definition is not a column and so
808
- // has no property to be found. Dropped here until now — which meant
809
- // `_distance` was computed for every vector search on this path and
810
- // then discarded before the caller ever saw it.
811
- if (QUERY_METADATA_KEYS.has(key)) {
812
- result[key] = value;
813
- continue;
814
- }
815
-
816
- const property = properties[key as keyof M] as Property;
817
- if (!property) continue; // Skip DB columns not defined in properties
818
-
819
- if (options.skipRelations && property.type === "relation") continue;
820
-
821
- result[key] = parsePropertyFromServer(value, property, collection, key);
822
- }
823
-
824
- return result;
825
- }
826
-
827
- /**
828
- * Lightweight value normalization for db.query results.
829
- * Only handles type coercion (dates, numbers, NaN) and property filtering.
830
- * Does NOT query the database for relations — those are already resolved
831
- * by Drizzle's relational query API.
832
- *
833
- * Use this instead of `parseDataFromServer` when processing results from
834
- * `db.query.findFirst/findMany` which return pre-hydrated relation data.
835
- */
836
- export function normalizeDbValues<M extends Record<string, unknown>>(
837
- data: M,
838
- collection: CollectionConfig
839
- ): M {
840
- const properties = collection.properties;
841
- if (!data || !properties) return data;
842
-
843
- const resolvedRelations = resolveCollectionRelations(collection);
844
- return normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: true }) as M;
845
- }