@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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
package/src/data-transformer.ts
DELETED
|
@@ -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
|
-
}
|