@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e

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 (147) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +48 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +36 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +12 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +10635 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +10643 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +112 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +188 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +7 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +36 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +767 -0
  43. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  45. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  46. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +195 -0
  47. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  48. package/dist/server-postgresql/src/services/RelationService.d.ts +92 -0
  49. package/dist/server-postgresql/src/services/entity-helpers.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/entityService.d.ts +102 -0
  51. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  52. package/dist/server-postgresql/src/services/realtimeService.d.ts +186 -0
  53. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  54. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  55. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  56. package/dist/types/src/controllers/auth.d.ts +117 -0
  57. package/dist/types/src/controllers/client.d.ts +58 -0
  58. package/dist/types/src/controllers/collection_registry.d.ts +44 -0
  59. package/dist/types/src/controllers/customization_controller.d.ts +54 -0
  60. package/dist/types/src/controllers/data.d.ts +141 -0
  61. package/dist/types/src/controllers/data_driver.d.ts +168 -0
  62. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  63. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  64. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  65. package/dist/types/src/controllers/index.d.ts +17 -0
  66. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  67. package/dist/types/src/controllers/navigation.d.ts +213 -0
  68. package/dist/types/src/controllers/registry.d.ts +51 -0
  69. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  70. package/dist/types/src/controllers/side_entity_controller.d.ts +89 -0
  71. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  72. package/dist/types/src/controllers/storage.d.ts +173 -0
  73. package/dist/types/src/index.d.ts +4 -0
  74. package/dist/types/src/rebase_context.d.ts +101 -0
  75. package/dist/types/src/types/backend.d.ts +533 -0
  76. package/dist/types/src/types/builders.d.ts +14 -0
  77. package/dist/types/src/types/chips.d.ts +5 -0
  78. package/dist/types/src/types/collections.d.ts +812 -0
  79. package/dist/types/src/types/data_source.d.ts +64 -0
  80. package/dist/types/src/types/entities.d.ts +145 -0
  81. package/dist/types/src/types/entity_actions.d.ts +98 -0
  82. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  83. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  84. package/dist/types/src/types/entity_overrides.d.ts +9 -0
  85. package/dist/types/src/types/entity_views.d.ts +61 -0
  86. package/dist/types/src/types/export_import.d.ts +21 -0
  87. package/dist/types/src/types/index.d.ts +22 -0
  88. package/dist/types/src/types/locales.d.ts +4 -0
  89. package/dist/types/src/types/modify_collections.d.ts +5 -0
  90. package/dist/types/src/types/plugins.d.ts +225 -0
  91. package/dist/types/src/types/properties.d.ts +1091 -0
  92. package/dist/types/src/types/property_config.d.ts +70 -0
  93. package/dist/types/src/types/relations.d.ts +336 -0
  94. package/dist/types/src/types/slots.d.ts +228 -0
  95. package/dist/types/src/types/translations.d.ts +826 -0
  96. package/dist/types/src/types/user_management_delegate.d.ts +120 -0
  97. package/dist/types/src/types/websockets.d.ts +78 -0
  98. package/dist/types/src/users/index.d.ts +2 -0
  99. package/dist/types/src/users/roles.d.ts +22 -0
  100. package/dist/types/src/users/user.d.ts +46 -0
  101. package/jest-all.log +3128 -0
  102. package/jest.log +49 -0
  103. package/package.json +93 -0
  104. package/src/PostgresBackendDriver.ts +1024 -0
  105. package/src/PostgresBootstrapper.ts +232 -0
  106. package/src/auth/ensure-tables.ts +309 -0
  107. package/src/auth/services.ts +740 -0
  108. package/src/cli.ts +347 -0
  109. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  110. package/src/connection.ts +62 -0
  111. package/src/data-transformer.ts +569 -0
  112. package/src/databasePoolManager.ts +84 -0
  113. package/src/history/HistoryService.ts +257 -0
  114. package/src/history/ensure-history-table.ts +45 -0
  115. package/src/index.ts +13 -0
  116. package/src/interfaces.ts +60 -0
  117. package/src/schema/auth-schema.ts +146 -0
  118. package/src/schema/generate-drizzle-schema-logic.ts +618 -0
  119. package/src/schema/generate-drizzle-schema.ts +151 -0
  120. package/src/services/BranchService.ts +237 -0
  121. package/src/services/EntityFetchService.ts +1447 -0
  122. package/src/services/EntityPersistService.ts +351 -0
  123. package/src/services/RelationService.ts +1012 -0
  124. package/src/services/entity-helpers.ts +121 -0
  125. package/src/services/entityService.ts +209 -0
  126. package/src/services/index.ts +13 -0
  127. package/src/services/realtimeService.ts +1005 -0
  128. package/src/utils/drizzle-conditions.ts +999 -0
  129. package/src/websocket.ts +487 -0
  130. package/test/auth-services.test.ts +569 -0
  131. package/test/branchService.test.ts +357 -0
  132. package/test/drizzle-conditions.test.ts +895 -0
  133. package/test/entityService.errors.test.ts +352 -0
  134. package/test/entityService.relations.test.ts +912 -0
  135. package/test/entityService.subcollection-search.test.ts +516 -0
  136. package/test/entityService.test.ts +977 -0
  137. package/test/generate-drizzle-schema.test.ts +795 -0
  138. package/test/historyService.test.ts +126 -0
  139. package/test/postgresDataDriver.test.ts +556 -0
  140. package/test/realtimeService.test.ts +276 -0
  141. package/test/relations.test.ts +662 -0
  142. package/test_drizzle_mock.js +3 -0
  143. package/test_find_changed.mjs +30 -0
  144. package/test_output.txt +3145 -0
  145. package/tsconfig.json +49 -0
  146. package/tsconfig.prod.json +20 -0
  147. package/vite.config.ts +82 -0
@@ -0,0 +1,569 @@
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 { EntityCollection, Properties, Property, Relation, RelationProperty } from "@rebasepro/types";
5
+ import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
6
+ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
7
+ import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
8
+ import { getPrimaryKeys, buildCompositeId } from "./services/entity-helpers";
9
+
10
+ /**
11
+ * Data transformation utilities for converting between frontend and database formats.
12
+ */
13
+
14
+ /**
15
+ * Helper function to sanitize and convert dates to ISO strings
16
+ */
17
+ export function sanitizeAndConvertDates(obj: unknown): unknown {
18
+ if (obj === null || obj === undefined) {
19
+ return null;
20
+ }
21
+
22
+ if (typeof obj === "number" && isNaN(obj)) {
23
+ return null;
24
+ }
25
+
26
+ if (typeof obj === "string" && obj.toLowerCase() === "nan") {
27
+ return null;
28
+ }
29
+
30
+ if (Array.isArray(obj)) {
31
+ return obj.map(v => sanitizeAndConvertDates(v));
32
+ }
33
+
34
+ if (obj instanceof Date) {
35
+ return obj.toISOString();
36
+ }
37
+
38
+ if (typeof obj === "object") {
39
+ const newObj: Record<string, unknown> = {};
40
+ for (const key in obj) {
41
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
42
+ newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
43
+ }
44
+ }
45
+ return newObj;
46
+ }
47
+
48
+ if (typeof obj === "string") {
49
+ const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
50
+ const jsDateRegex = /^\w{3} \w{3} \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4} \(.+\)$/;
51
+ if (isoDateRegex.test(obj) || jsDateRegex.test(obj)) {
52
+ const date = new Date(obj);
53
+ if (!isNaN(date.getTime())) {
54
+ return date.toISOString();
55
+ }
56
+ }
57
+ }
58
+
59
+ return obj;
60
+ }
61
+
62
+ /**
63
+ * Transform relations for database storage (relation objects to IDs)
64
+ */
65
+ export function serializeDataToServer<M extends Record<string, any>>(
66
+ entity: M,
67
+ properties: Properties,
68
+ collection?: EntityCollection,
69
+ registry?: PostgresCollectionRegistry
70
+ ): Record<string, unknown> {
71
+ if (!entity || !properties) return entity;
72
+
73
+ const result: Record<string, unknown> = {};
74
+
75
+ // Get normalized relations if collection is provided
76
+ const resolvedRelations = collection ? resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>) : {};
77
+
78
+ // Track inverse relations that need to be handled separately
79
+ const inverseRelationUpdates: Array<{
80
+ relationKey: string;
81
+ relation: Relation;
82
+ newValue: unknown;
83
+ currentEntityId?: string | number;
84
+ }> = [];
85
+ const joinPathRelationUpdates: Array<{
86
+ relationKey: string;
87
+ relation: Relation;
88
+ newTargetId: string | number | null;
89
+ }> = [];
90
+
91
+ for (const [key, value] of Object.entries(entity)) {
92
+ const property = properties[key as keyof M] as Property;
93
+ if (!property) {
94
+ result[key] = value;
95
+ continue;
96
+ }
97
+
98
+ // Handle relation properties specially
99
+ if (property.type === "relation" && collection) {
100
+ const relation = resolvedRelations[key];
101
+ if (relation) {
102
+ if (relation.direction === "owning" && relation.localKey) {
103
+ // Owning relation: Map relation object to FK column on current table
104
+ const serializedValue = serializePropertyToServer(value, property);
105
+ if (serializedValue !== null && serializedValue !== undefined) {
106
+ result[relation.localKey] = serializedValue;
107
+ }
108
+ // Don't add the original relation property to the result
109
+ continue;
110
+ } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
111
+ // Inverse relation: Need to update the target table's FK
112
+ const serializedValue = serializePropertyToServer(value, property);
113
+ const pks = getPrimaryKeys(collection, registry!);
114
+ inverseRelationUpdates.push({
115
+ relationKey: key,
116
+ relation,
117
+ newValue: serializedValue,
118
+ currentEntityId: entity.id || buildCompositeId(entity, pks)
119
+ });
120
+ // Don't add the original relation property to the result
121
+ continue;
122
+ } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
123
+ const serializedValue = serializePropertyToServer(value, property);
124
+ if (relation.cardinality === "one") {
125
+ // One-to-one inverse joinPath: route through joinPathRelationUpdates.
126
+ // The write ordering in EntityPersistService ensures these are processed
127
+ // BEFORE the main UPDATE, so parentSourceCol reads the pre-update FK value.
128
+ // This prevents stale values from corrupting related entities when an
129
+ // intermediate FK (e.g. author_id) changes in the same save.
130
+ joinPathRelationUpdates.push({
131
+ relationKey: key,
132
+ relation,
133
+ newTargetId: serializedValue as string | number | null
134
+ });
135
+ } else {
136
+ // Many inverse joinPath: capture as inverse relation update
137
+ const pks = getPrimaryKeys(collection, registry!);
138
+ inverseRelationUpdates.push({
139
+ relationKey: key,
140
+ relation,
141
+ newValue: serializedValue,
142
+ currentEntityId: entity.id || buildCompositeId(entity, pks)
143
+ });
144
+ }
145
+ // Don't add the original relation property to the result
146
+ continue;
147
+ } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
148
+ // Owning one-to-one via joinPath: capture as a write intent
149
+ const serializedValue = serializePropertyToServer(value, property);
150
+ joinPathRelationUpdates.push({
151
+ relationKey: key,
152
+ relation,
153
+ newTargetId: serializedValue as string | number | null
154
+ });
155
+ // Don't include this property directly in payload
156
+ continue;
157
+ }
158
+ }
159
+ }
160
+
161
+ result[key] = serializePropertyToServer(value, property);
162
+ }
163
+
164
+ if (inverseRelationUpdates.length > 0) {
165
+ (result as Record<string, unknown>).__inverseRelationUpdates = inverseRelationUpdates;
166
+ }
167
+ if (joinPathRelationUpdates.length > 0) {
168
+ (result as Record<string, unknown>).__joinPathRelationUpdates = joinPathRelationUpdates;
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * Serialize a single property value for database storage
176
+ */
177
+ export function serializePropertyToServer(value: unknown, property: Property): unknown {
178
+ if (value === null || value === undefined) {
179
+ return value;
180
+ }
181
+
182
+ const propertyType = property.type;
183
+
184
+ switch (propertyType) {
185
+ case "relation":
186
+ if (Array.isArray(value)) {
187
+ return value.map(v => serializePropertyToServer(v, property));
188
+ } else if (typeof value === "object" && value !== null && "id" in value) {
189
+ return (value as Record<string, unknown>).id;
190
+ }
191
+ return value;
192
+
193
+ case "array":
194
+ if (Array.isArray(value) && property.of) {
195
+ return value.map(item => serializePropertyToServer(item, property.of as Property));
196
+ }
197
+ return value;
198
+
199
+ case "map":
200
+ if (typeof value === "object" && property.properties) {
201
+ const result: Record<string, unknown> = {};
202
+ for (const [subKey, subValue] of Object.entries(value)) {
203
+ const subProperty = (property.properties as Properties)[subKey];
204
+ if (subProperty) {
205
+ result[subKey] = serializePropertyToServer(subValue, subProperty);
206
+ } else {
207
+ result[subKey] = subValue;
208
+ }
209
+ }
210
+ return result;
211
+ }
212
+ return value;
213
+
214
+ default:
215
+ return value;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Transform IDs back to relation objects for frontend
221
+ */
222
+ export async function parseDataFromServer<M extends Record<string, any>>(
223
+ data: M,
224
+ collection: EntityCollection,
225
+ db?: NodePgDatabase<any>,
226
+ registry?: PostgresCollectionRegistry
227
+ ): Promise<M> {
228
+ const properties = collection.properties;
229
+ if (!data || !properties) return data;
230
+
231
+ const result: Record<string, unknown> = {};
232
+
233
+ // Get the normalized relations once
234
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
235
+
236
+ // Get list of FK columns that are used only for relations and not defined as properties
237
+ const internalFKColumns = new Set<string>();
238
+ Object.values(resolvedRelations).forEach(relation => {
239
+ if (relation.localKey && !properties[relation.localKey]) {
240
+ // This FK is used internally but not exposed as a property
241
+ internalFKColumns.add(relation.localKey);
242
+ }
243
+ });
244
+
245
+ // Process only the properties that are defined in the collection
246
+ for (const [key, value] of Object.entries(data)) {
247
+ // Skip internal FK columns that aren't defined as properties
248
+ if (internalFKColumns.has(key)) {
249
+ continue;
250
+ }
251
+
252
+ const property = properties[key as keyof M] as Property;
253
+ if (!property) {
254
+ // Also skip any other database columns not defined in properties
255
+ continue;
256
+ }
257
+
258
+ result[key] = parsePropertyFromServer(value, property, collection, key);
259
+ }
260
+
261
+ // Add relation properties that should be populated from FK values or inverse queries
262
+ for (const [propKey, property] of Object.entries(properties)) {
263
+ if (property.type === "relation" && !(propKey in result)) {
264
+ // Find the normalized relation for this property
265
+ const relation = resolvedRelations[propKey];
266
+ if (relation) {
267
+ if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
268
+ // Owning relation: FK is in current table
269
+ const fkValue = data[relation.localKey as keyof M];
270
+ if (fkValue !== null && fkValue !== undefined) {
271
+ try {
272
+ const targetCollection = relation.target();
273
+ result[propKey] = {
274
+ id: fkValue.toString(),
275
+ path: targetCollection.slug,
276
+ __type: "relation"
277
+ };
278
+ } catch (e) {
279
+ console.warn(`Could not resolve target collection for relation property: ${propKey}`, e);
280
+ }
281
+ }
282
+ } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) {
283
+ // Inverse relation: FK is in target table, need to query for it
284
+ try {
285
+ const targetCollection = relation.target();
286
+ const targetTable = registry.getTable(getTableName(targetCollection));
287
+ const pks = getPrimaryKeys(collection, registry!);
288
+ const currentEntityId = buildCompositeId(data, pks);
289
+
290
+ if (targetTable && currentEntityId) {
291
+ const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
292
+ if (foreignKeyColumn) {
293
+ // Query the target table to find entity that references this entity
294
+ const relatedEntities = await db
295
+ .select()
296
+ .from(targetTable)
297
+ .where(eq(foreignKeyColumn, currentEntityId))
298
+ .limit(relation.cardinality === "one" ? 1 : 100); // Limit for one-to-one vs one-to-many
299
+
300
+ if (relatedEntities.length > 0) {
301
+ if (relation.cardinality === "one") {
302
+ // One-to-one: return single relation object
303
+ const targetPks = getPrimaryKeys(targetCollection, registry!);
304
+ const relatedEntity = relatedEntities[0] as Record<string, unknown>;
305
+ result[propKey] = {
306
+ id: buildCompositeId(relatedEntity, targetPks),
307
+ path: targetCollection.slug,
308
+ __type: "relation"
309
+ };
310
+ } else {
311
+ // One-to-many: return array of relation objects
312
+ const targetPks = getPrimaryKeys(targetCollection, registry!);
313
+ result[propKey] = relatedEntities.map((entity: Record<string, unknown>) => ({
314
+ id: buildCompositeId(entity, targetPks),
315
+ path: targetCollection.slug,
316
+ __type: "relation"
317
+ }));
318
+ }
319
+ }
320
+ }
321
+ }
322
+ } catch (e) {
323
+ console.warn(`Could not resolve inverse relation property: ${propKey}`, e);
324
+ }
325
+ } else if (relation.direction === "inverse" && relation.joinPath && db && registry) {
326
+ // Join path relation: Multi-hop relation using joins
327
+ try {
328
+ const targetCollection = relation.target();
329
+ const pks = getPrimaryKeys(collection, registry!);
330
+ const currentEntityId = buildCompositeId(data, pks);
331
+
332
+ if (currentEntityId) {
333
+ // Build the join query following the join path
334
+ const sourceTable = registry.getTable(getTableName(collection));
335
+ if (!sourceTable) {
336
+ console.warn(`Source table not found for collection: ${collection.slug}`);
337
+ continue;
338
+ }
339
+
340
+ let query = db.select().from(sourceTable);
341
+ let currentTable = sourceTable;
342
+
343
+ // Apply each join in the path
344
+ for (const join of relation.joinPath) {
345
+ const joinTable = registry.getTable(join.table);
346
+ if (!joinTable) {
347
+ console.warn(`Join table not found: ${join.table}`);
348
+ break;
349
+ }
350
+
351
+ // Parse the join condition - handle both string and array formats
352
+ const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
353
+ const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
354
+
355
+ const fromParts = fromColumn.split(".");
356
+ const toParts = toColumn.split(".");
357
+
358
+ const fromColName = fromParts[fromParts.length - 1];
359
+ const toColName = toParts[toParts.length - 1];
360
+
361
+ const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
362
+ const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
363
+
364
+ if (!fromCol || !toCol) {
365
+ console.warn(`Join columns not found: ${fromColumn} -> ${toColumn}`);
366
+ break;
367
+ }
368
+
369
+ query = query.innerJoin(joinTable, eq(fromCol, toCol)) as unknown as typeof query;
370
+ currentTable = joinTable;
371
+ }
372
+
373
+ // Add where condition for the current entity
374
+ if (pks.length === 1) {
375
+ const sourceIdField = sourceTable[pks[0].fieldName as keyof typeof sourceTable] as AnyPgColumn;
376
+ query = query.where(eq(sourceIdField, currentEntityId)) as unknown as typeof query;
377
+ } else {
378
+ // For composite keys, we would need to map the split parts. For now log a warning.
379
+ console.warn(`Join path resolution for composite primary keys is not yet fully supported: ${collection.slug}`);
380
+ }
381
+
382
+ // Build additional conditions array
383
+ const additionalFilters: SQL[] = [];
384
+
385
+ // Combine parent condition with additional filters using AND
386
+ let combinedWhere: SQL | undefined;
387
+
388
+ if (pks.length === 1) {
389
+ const sourceIdField = sourceTable[pks[0].fieldName as keyof typeof sourceTable] as AnyPgColumn;
390
+ combinedWhere = DrizzleConditionBuilder.combineConditionsWithAnd([
391
+ eq(sourceIdField, currentEntityId),
392
+ ...additionalFilters
393
+ ].filter(Boolean) as SQL[]);
394
+ }
395
+
396
+ // Execute the query
397
+ const joinResults = await query.where(combinedWhere).limit(relation.cardinality === "one" ? 1 : 100);
398
+
399
+ if (joinResults.length > 0) {
400
+ const targetPks = getPrimaryKeys(targetCollection, registry!);
401
+ const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
402
+
403
+ if (relation.cardinality === "one") {
404
+ // One-to-one: return single relation object
405
+ const joinResult = joinResults[0] as Record<string, unknown>;
406
+ const targetEntity = joinResult[targetTableName] || joinResult;
407
+ result[propKey] = {
408
+ id: buildCompositeId(targetEntity, targetPks),
409
+ path: targetCollection.slug,
410
+ __type: "relation"
411
+ };
412
+ } else {
413
+ // One-to-many: return array of relation objects
414
+ result[propKey] = joinResults.map((joinResult: Record<string, unknown>) => {
415
+ const targetEntity = joinResult[targetTableName] || joinResult;
416
+ return {
417
+ id: buildCompositeId(targetEntity, targetPks),
418
+ path: targetCollection.slug,
419
+ __type: "relation"
420
+ };
421
+ });
422
+ }
423
+ }
424
+ }
425
+ } catch (e) {
426
+ console.warn(`Could not resolve join path relation property: ${propKey}`, e);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ return result as M;
434
+ }
435
+
436
+ /**
437
+ * Parse a single property value from database format to frontend format
438
+ */
439
+ export function parsePropertyFromServer(value: unknown, property: Property, collection: EntityCollection, propertyKey?: string): unknown {
440
+ if (value === null || value === undefined) {
441
+ return value;
442
+ }
443
+
444
+ switch (property.type) {
445
+ case "relation":
446
+ // Transform ID back to relation object with type information
447
+ if (typeof value === "string" || typeof value === "number") {
448
+ let relationDef: Relation | undefined = (property as RelationProperty).relation;
449
+ if (!relationDef && propertyKey) {
450
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
451
+ relationDef = resolvedRelations[propertyKey];
452
+ }
453
+ if (!relationDef) {
454
+ relationDef = (collection as import("@rebasepro/types").PostgresCollection<any, any>).relations?.find((rel) => rel.relationName === (property as RelationProperty).relationName);
455
+ }
456
+
457
+ if (!relationDef) {
458
+ console.warn(`Relation not defined in property for key: ${propertyKey || 'unknown'}`);
459
+ return value;
460
+ }
461
+
462
+ try {
463
+ const targetCollection = relationDef.target();
464
+ return {
465
+ id: value.toString(),
466
+ path: targetCollection.slug,
467
+ __type: "relation"
468
+ };
469
+ } catch (e) {
470
+ console.warn(`Could not resolve target collection for relation property: ${propertyKey || 'unknown'}`, e);
471
+ return value;
472
+ }
473
+ }
474
+ return value;
475
+
476
+ case "array":
477
+ if (Array.isArray(value) && property.of) {
478
+ return value.map(item => parsePropertyFromServer(item, property.of as Property, collection));
479
+ }
480
+ return value;
481
+
482
+ case "map":
483
+ if (typeof value === "object" && property.properties) {
484
+ const result: Record<string, unknown> = {};
485
+ for (const [subKey, subValue] of Object.entries(value)) {
486
+ const subProperty = (property.properties as Properties)[subKey];
487
+ if (subProperty) {
488
+ result[subKey] = parsePropertyFromServer(subValue, subProperty, collection);
489
+ } else {
490
+ result[subKey] = subValue;
491
+ }
492
+ }
493
+ return result;
494
+ }
495
+ return value;
496
+
497
+ case "number":
498
+ if (typeof value === "string") {
499
+ const parsed = parseFloat(value);
500
+ return isNaN(parsed) ? null : parsed;
501
+ }
502
+ return value;
503
+
504
+ case "date": {
505
+ let date: Date | undefined;
506
+ if (value instanceof Date) {
507
+ date = value;
508
+ } else if (typeof value === "string" || typeof value === "number") {
509
+ const parsedDate = new Date(value);
510
+ if (!isNaN(parsedDate.getTime())) {
511
+ date = parsedDate;
512
+ }
513
+ }
514
+ if (date) {
515
+ return {
516
+ __type: "date",
517
+ value: date.toISOString()
518
+ };
519
+ }
520
+ return null;
521
+ }
522
+
523
+ default:
524
+ return value;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Lightweight value normalization for db.query results.
530
+ * Only handles type coercion (dates, numbers, NaN) and property filtering.
531
+ * Does NOT query the database for relations — those are already resolved
532
+ * by Drizzle's relational query API.
533
+ *
534
+ * Use this instead of `parseDataFromServer` when processing results from
535
+ * `db.query.findFirst/findMany` which return pre-hydrated relation data.
536
+ */
537
+ export function normalizeDbValues<M extends Record<string, any>>(
538
+ data: M,
539
+ collection: EntityCollection
540
+ ): M {
541
+ const properties = collection.properties;
542
+ if (!data || !properties) return data;
543
+
544
+ const result: Record<string, unknown> = {};
545
+
546
+ // Get FK columns that are used internally for relations and not defined as properties
547
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
548
+ const internalFKColumns = new Set<string>();
549
+ Object.values(resolvedRelations).forEach(relation => {
550
+ if (relation.localKey && !properties[relation.localKey]) {
551
+ internalFKColumns.add(relation.localKey);
552
+ }
553
+ });
554
+
555
+ for (const [key, value] of Object.entries(data)) {
556
+ // Skip internal FK columns
557
+ if (internalFKColumns.has(key)) continue;
558
+
559
+ const property = properties[key as keyof M] as Property;
560
+ if (!property) continue; // Skip DB columns not defined in properties
561
+
562
+ // Skip relation properties — they're already handled by db.query
563
+ if (property.type === "relation") continue;
564
+
565
+ result[key] = parsePropertyFromServer(value, property, collection, key);
566
+ }
567
+
568
+ return result as M;
569
+ }
@@ -0,0 +1,84 @@
1
+ import { Pool } from 'pg';
2
+ import { drizzle } from 'drizzle-orm/node-postgres';
3
+ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
4
+
5
+ export class DatabasePoolManager {
6
+ private pools: Map<string, Pool> = new Map();
7
+ private drizzleInstances: Map<string, NodePgDatabase> = new Map();
8
+ public readonly defaultDatabaseName: string;
9
+ private readonly rootConnectionString: string;
10
+
11
+ constructor(adminConnectionString: string) {
12
+ this.rootConnectionString = adminConnectionString;
13
+ try {
14
+ const url = new URL(adminConnectionString);
15
+ this.defaultDatabaseName = url.pathname.slice(1);
16
+ } catch (e) {
17
+ throw new Error(`Invalid adminConnectionString provided: ${e}`);
18
+ }
19
+ }
20
+
21
+ public getDrizzle(databaseName: string): NodePgDatabase<any> {
22
+ const existing = this.drizzleInstances.get(databaseName);
23
+ if (existing) {
24
+ return existing;
25
+ }
26
+
27
+ const pool = this.getPool(databaseName);
28
+ const db = drizzle(pool);
29
+ this.drizzleInstances.set(databaseName, db);
30
+ return db;
31
+ }
32
+
33
+ public getPool(databaseName: string): Pool {
34
+ if (this.pools.has(databaseName)) {
35
+ return this.pools.get(databaseName)!;
36
+ }
37
+
38
+ const url = new URL(this.rootConnectionString);
39
+ url.pathname = `/${databaseName}`;
40
+
41
+ const pool = new Pool({
42
+ connectionString: url.toString(),
43
+ max: 10, // Default sensible limit, can be tuned later
44
+ idleTimeoutMillis: 30000,
45
+ });
46
+
47
+ // Prevent idle client errors from crashing the Node.js process
48
+ pool.on('error', (err) => {
49
+ console.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, err);
50
+ });
51
+
52
+ this.pools.set(databaseName, pool);
53
+ return pool;
54
+ }
55
+
56
+ /**
57
+ * Disconnect and remove the pool for a specific database.
58
+ * Required before `CREATE DATABASE ... TEMPLATE` or `DROP DATABASE`,
59
+ * which need exclusive access to the target database.
60
+ */
61
+ public async disconnectDatabase(databaseName: string): Promise<void> {
62
+ const pool = this.pools.get(databaseName);
63
+ if (pool) {
64
+ await pool.end();
65
+ this.pools.delete(databaseName);
66
+ this.drizzleInstances.delete(databaseName);
67
+ }
68
+ }
69
+
70
+ /** Check if a pool exists for a given database name. */
71
+ public hasPool(databaseName: string): boolean {
72
+ return this.pools.has(databaseName);
73
+ }
74
+
75
+ public async shutdown(): Promise<void> {
76
+ const promises = [];
77
+ for (const [dbName, pool] of this.pools.entries()) {
78
+ console.log(`[DatabasePoolManager] Shutting down pool for ${dbName}`);
79
+ promises.push(pool.end());
80
+ }
81
+ await Promise.all(promises);
82
+ this.pools.clear();
83
+ }
84
+ }