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

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 (136) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index.es.js +1254 -591
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +1254 -591
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
  10. package/dist/server-postgresql/src/auth/services.d.ts +7 -3
  11. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
  12. package/dist/server-postgresql/src/connection.d.ts +34 -1
  13. package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
  14. package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
  15. package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
  16. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  17. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  18. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
  19. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  20. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
  21. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
  22. package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
  23. package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
  24. package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
  25. package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
  26. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
  27. package/dist/types/src/controllers/auth.d.ts +2 -0
  28. package/dist/types/src/controllers/client.d.ts +119 -7
  29. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  30. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  31. package/dist/types/src/controllers/data.d.ts +34 -7
  32. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  33. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  34. package/dist/types/src/controllers/email.d.ts +34 -0
  35. package/dist/types/src/controllers/index.d.ts +1 -0
  36. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  37. package/dist/types/src/controllers/navigation.d.ts +5 -5
  38. package/dist/types/src/controllers/registry.d.ts +6 -3
  39. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  40. package/dist/types/src/controllers/storage.d.ts +24 -26
  41. package/dist/types/src/rebase_context.d.ts +8 -4
  42. package/dist/types/src/types/backend.d.ts +4 -1
  43. package/dist/types/src/types/builders.d.ts +5 -4
  44. package/dist/types/src/types/chips.d.ts +1 -1
  45. package/dist/types/src/types/collections.d.ts +169 -125
  46. package/dist/types/src/types/cron.d.ts +102 -0
  47. package/dist/types/src/types/data_source.d.ts +1 -1
  48. package/dist/types/src/types/entity_actions.d.ts +8 -8
  49. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  50. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  51. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  52. package/dist/types/src/types/entity_views.d.ts +8 -8
  53. package/dist/types/src/types/export_import.d.ts +3 -3
  54. package/dist/types/src/types/index.d.ts +1 -0
  55. package/dist/types/src/types/plugins.d.ts +72 -18
  56. package/dist/types/src/types/properties.d.ts +118 -33
  57. package/dist/types/src/types/relations.d.ts +1 -1
  58. package/dist/types/src/types/slots.d.ts +30 -6
  59. package/dist/types/src/types/translations.d.ts +44 -0
  60. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  61. package/drizzle-test/0000_woozy_junta.sql +6 -0
  62. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  63. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  64. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  65. package/drizzle-test/meta/0000_snapshot.json +47 -0
  66. package/drizzle-test/meta/0001_snapshot.json +48 -0
  67. package/drizzle-test/meta/0002_snapshot.json +38 -0
  68. package/drizzle-test/meta/0003_snapshot.json +48 -0
  69. package/drizzle-test/meta/_journal.json +34 -0
  70. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  71. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  72. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  73. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  74. package/drizzle-test-out/meta/_journal.json +20 -0
  75. package/drizzle.test.config.ts +10 -0
  76. package/package.json +88 -89
  77. package/scratch.ts +41 -0
  78. package/src/PostgresBackendDriver.ts +63 -79
  79. package/src/PostgresBootstrapper.ts +7 -8
  80. package/src/auth/ensure-tables.ts +158 -86
  81. package/src/auth/services.ts +109 -50
  82. package/src/cli.ts +259 -16
  83. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  84. package/src/connection.ts +70 -48
  85. package/src/data-transformer.ts +155 -116
  86. package/src/databasePoolManager.ts +6 -5
  87. package/src/history/HistoryService.ts +3 -12
  88. package/src/interfaces.ts +3 -3
  89. package/src/schema/auth-schema.ts +26 -3
  90. package/src/schema/doctor-cli.ts +47 -0
  91. package/src/schema/doctor.ts +595 -0
  92. package/src/schema/generate-drizzle-schema-logic.ts +204 -57
  93. package/src/schema/generate-drizzle-schema.ts +6 -6
  94. package/src/schema/test-schema.ts +11 -0
  95. package/src/services/BranchService.ts +5 -5
  96. package/src/services/EntityFetchService.ts +317 -188
  97. package/src/services/EntityPersistService.ts +15 -17
  98. package/src/services/RelationService.ts +299 -37
  99. package/src/services/entity-helpers.ts +39 -13
  100. package/src/services/entityService.ts +11 -9
  101. package/src/services/realtimeService.ts +58 -29
  102. package/src/utils/drizzle-conditions.ts +25 -24
  103. package/src/websocket.ts +52 -21
  104. package/test/auth-services.test.ts +131 -39
  105. package/test/batch-many-to-many-regression.test.ts +573 -0
  106. package/test/branchService.test.ts +22 -12
  107. package/test/data-transformer-hardening.test.ts +417 -0
  108. package/test/data-transformer.test.ts +175 -0
  109. package/test/doctor.test.ts +182 -0
  110. package/test/entityService.errors.test.ts +31 -16
  111. package/test/entityService.relations.test.ts +155 -59
  112. package/test/entityService.subcollection-search.test.ts +107 -57
  113. package/test/entityService.test.ts +105 -47
  114. package/test/generate-drizzle-schema.test.ts +262 -69
  115. package/test/historyService.test.ts +31 -16
  116. package/test/n-plus-one-regression.test.ts +314 -0
  117. package/test/postgresDataDriver.test.ts +260 -168
  118. package/test/realtimeService.test.ts +70 -39
  119. package/test/relation-pipeline-gaps.test.ts +637 -0
  120. package/test/relations.test.ts +492 -39
  121. package/test-drizzle-bug.ts +18 -0
  122. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  123. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  124. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  125. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  126. package/test-drizzle-out/meta/_journal.json +20 -0
  127. package/test-drizzle-prompt.sh +2 -0
  128. package/test-policy-prompt.sh +3 -0
  129. package/test-programmatic.ts +30 -0
  130. package/test-programmatic2.ts +59 -0
  131. package/test-schema-no-policies.ts +12 -0
  132. package/test_drizzle_mock.js +2 -2
  133. package/test_find_changed.mjs +3 -1
  134. package/test_hash.js +14 -0
  135. package/tsconfig.json +1 -1
  136. package/vite.config.ts +5 -5
@@ -1,8 +1,8 @@
1
1
  import { eq, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn } from "drizzle-orm/pg-core";
3
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";
4
+ import { CollectionWithRelations, EntityCollection, Properties, Property, Relation, RelationProperty } from "@rebasepro/types";
5
+ import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
6
6
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
7
7
  import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
8
8
  import { getPrimaryKeys, buildCompositeId } from "./services/entity-helpers";
@@ -11,6 +11,28 @@ import { getPrimaryKeys, buildCompositeId } from "./services/entity-helpers";
11
11
  * Data transformation utilities for converting between frontend and database formats.
12
12
  */
13
13
 
14
+ /**
15
+ * Typed result from `serializeDataToServer`.
16
+ * Replaces the hidden `__inverseRelationUpdates` / `__joinPathRelationUpdates`
17
+ * dunder-property mutation pattern with explicit, typed state management.
18
+ */
19
+ export interface SerializedEntityData {
20
+ /** Scalar column values ready for INSERT/UPDATE. */
21
+ scalarData: Record<string, unknown>;
22
+ /** Inverse relation updates that must be applied to target tables. */
23
+ inverseRelationUpdates: Array<{
24
+ relationKey: string;
25
+ relation: Relation;
26
+ newValue: unknown;
27
+ currentEntityId?: string | number;
28
+ }>;
29
+ /** JoinPath relation updates that require multi-hop writes. */
30
+ joinPathRelationUpdates: Array<{
31
+ relationKey: string;
32
+ relation: Relation;
33
+ newTargetId: string | number | null;
34
+ }>;
35
+ }
14
36
  /**
15
37
  * Helper function to sanitize and convert dates to ISO strings
16
38
  */
@@ -62,18 +84,18 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
62
84
  /**
63
85
  * Transform relations for database storage (relation objects to IDs)
64
86
  */
65
- export function serializeDataToServer<M extends Record<string, any>>(
87
+ export function serializeDataToServer<M extends Record<string, unknown>>(
66
88
  entity: M,
67
89
  properties: Properties,
68
90
  collection?: EntityCollection,
69
91
  registry?: PostgresCollectionRegistry
70
- ): Record<string, unknown> {
71
- if (!entity || !properties) return entity;
92
+ ): SerializedEntityData {
93
+ if (!entity || !properties) return { scalarData: entity ?? {}, inverseRelationUpdates: [], joinPathRelationUpdates: [] };
72
94
 
73
95
  const result: Record<string, unknown> = {};
74
96
 
75
97
  // Get normalized relations if collection is provided
76
- const resolvedRelations = collection ? resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>) : {};
98
+ const resolvedRelations = collection ? resolveCollectionRelations(collection) : {};
77
99
 
78
100
  // Track inverse relations that need to be handled separately
79
101
  const inverseRelationUpdates: Array<{
@@ -88,39 +110,49 @@ export function serializeDataToServer<M extends Record<string, any>>(
88
110
  newTargetId: string | number | null;
89
111
  }> = [];
90
112
 
113
+ // Pre-calculate all local keys used as foreign keys
114
+ const foreignKeys = new Set<string>();
115
+ Object.values(resolvedRelations).forEach(relation => {
116
+ if (relation.localKey) foreignKeys.add(relation.localKey);
117
+ });
118
+
91
119
  for (const [key, value] of Object.entries(entity)) {
92
120
  const property = properties[key as keyof M] as Property;
121
+
122
+ // Coerce empty strings to null for any field that acts as a foreign key
123
+ const effectiveValue = (foreignKeys.has(key) && value === "") ? null : value;
124
+
93
125
  if (!property) {
94
- result[key] = value;
126
+ result[key] = effectiveValue;
95
127
  continue;
96
128
  }
97
129
 
98
130
  // Handle relation properties specially
99
131
  if (property.type === "relation" && collection) {
100
- const relation = resolvedRelations[key];
132
+ const relation = findRelation(resolvedRelations, key);
101
133
  if (relation) {
102
134
  if (relation.direction === "owning" && relation.localKey) {
103
135
  // Owning relation: Map relation object to FK column on current table
104
- const serializedValue = serializePropertyToServer(value, property);
105
- if (serializedValue !== null && serializedValue !== undefined) {
136
+ const serializedValue = serializePropertyToServer(effectiveValue, property);
137
+ if (serializedValue !== undefined) {
106
138
  result[relation.localKey] = serializedValue;
107
139
  }
108
140
  // Don't add the original relation property to the result
109
141
  continue;
110
142
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
111
143
  // Inverse relation: Need to update the target table's FK
112
- const serializedValue = serializePropertyToServer(value, property);
144
+ const serializedValue = serializePropertyToServer(effectiveValue, property);
113
145
  const pks = getPrimaryKeys(collection, registry!);
114
146
  inverseRelationUpdates.push({
115
147
  relationKey: key,
116
148
  relation,
117
149
  newValue: serializedValue,
118
- currentEntityId: entity.id || buildCompositeId(entity, pks)
150
+ currentEntityId: (entity.id as string | number | undefined) || buildCompositeId(entity, pks)
119
151
  });
120
152
  // Don't add the original relation property to the result
121
153
  continue;
122
154
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
123
- const serializedValue = serializePropertyToServer(value, property);
155
+ const serializedValue = serializePropertyToServer(effectiveValue, property);
124
156
  if (relation.cardinality === "one") {
125
157
  // One-to-one inverse joinPath: route through joinPathRelationUpdates.
126
158
  // The write ordering in EntityPersistService ensures these are processed
@@ -139,14 +171,14 @@ export function serializeDataToServer<M extends Record<string, any>>(
139
171
  relationKey: key,
140
172
  relation,
141
173
  newValue: serializedValue,
142
- currentEntityId: entity.id || buildCompositeId(entity, pks)
174
+ currentEntityId: (entity.id as string | number | undefined) || buildCompositeId(entity, pks)
143
175
  });
144
176
  }
145
177
  // Don't add the original relation property to the result
146
178
  continue;
147
179
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
148
180
  // Owning one-to-one via joinPath: capture as a write intent
149
- const serializedValue = serializePropertyToServer(value, property);
181
+ const serializedValue = serializePropertyToServer(effectiveValue, property);
150
182
  joinPathRelationUpdates.push({
151
183
  relationKey: key,
152
184
  relation,
@@ -158,17 +190,14 @@ export function serializeDataToServer<M extends Record<string, any>>(
158
190
  }
159
191
  }
160
192
 
161
- result[key] = serializePropertyToServer(value, property);
193
+ result[key] = serializePropertyToServer(effectiveValue, property);
162
194
  }
163
195
 
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;
196
+ return {
197
+ scalarData: result,
198
+ inverseRelationUpdates,
199
+ joinPathRelationUpdates
200
+ };
172
201
  }
173
202
 
174
203
  /**
@@ -188,11 +217,29 @@ export function serializePropertyToServer(value: unknown, property: Property): u
188
217
  } else if (typeof value === "object" && value !== null && "id" in value) {
189
218
  return (value as Record<string, unknown>).id;
190
219
  }
220
+ if (value === "") return null;
191
221
  return value;
192
222
 
193
223
  case "array":
194
- if (Array.isArray(value) && property.of) {
195
- return value.map(item => serializePropertyToServer(item, property.of as Property));
224
+ if (Array.isArray(value)) {
225
+ if (property.of) {
226
+ return value.map(item => serializePropertyToServer(item, property.of as Property));
227
+ } else if (property.oneOf) {
228
+ const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
229
+ const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
230
+ return value.map((e) => {
231
+ if (e === null) return null;
232
+ if (typeof e !== "object") return e;
233
+ const rec = e as Record<string, unknown>;
234
+ const type = rec[typeField] as string;
235
+ const childProperty = property.oneOf?.properties[type];
236
+ if (!type || !childProperty) return e;
237
+ return {
238
+ [typeField]: type,
239
+ [valueField]: serializePropertyToServer(rec[valueField], childProperty)
240
+ };
241
+ });
242
+ }
196
243
  }
197
244
  return value;
198
245
 
@@ -219,50 +266,26 @@ export function serializePropertyToServer(value: unknown, property: Property): u
219
266
  /**
220
267
  * Transform IDs back to relation objects for frontend
221
268
  */
222
- export async function parseDataFromServer<M extends Record<string, any>>(
269
+ export async function parseDataFromServer<M extends Record<string, unknown>>(
223
270
  data: M,
224
271
  collection: EntityCollection,
225
- db?: NodePgDatabase<any>,
272
+ db?: NodePgDatabase<Record<string, unknown>>,
226
273
  registry?: PostgresCollectionRegistry
227
274
  ): Promise<M> {
228
275
  const properties = collection.properties;
229
276
  if (!data || !properties) return data;
230
277
 
231
- const result: Record<string, unknown> = {};
232
-
233
278
  // Get the normalized relations once
234
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
279
+ const resolvedRelations = resolveCollectionRelations(collection);
235
280
 
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
- }
281
+ // Shared scalar + relation value normalization
282
+ const result = normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: false });
260
283
 
261
284
  // Add relation properties that should be populated from FK values or inverse queries
262
285
  for (const [propKey, property] of Object.entries(properties)) {
263
286
  if (property.type === "relation" && !(propKey in result)) {
264
287
  // Find the normalized relation for this property
265
- const relation = resolvedRelations[propKey];
288
+ const relation = findRelation(resolvedRelations, propKey);
266
289
  if (relation) {
267
290
  if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
268
291
  // Owning relation: FK is in current table
@@ -270,11 +293,7 @@ export async function parseDataFromServer<M extends Record<string, any>>(
270
293
  if (fkValue !== null && fkValue !== undefined) {
271
294
  try {
272
295
  const targetCollection = relation.target();
273
- result[propKey] = {
274
- id: fkValue.toString(),
275
- path: targetCollection.slug,
276
- __type: "relation"
277
- };
296
+ result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
278
297
  } catch (e) {
279
298
  console.warn(`Could not resolve target collection for relation property: ${propKey}`, e);
280
299
  }
@@ -302,19 +321,13 @@ export async function parseDataFromServer<M extends Record<string, any>>(
302
321
  // One-to-one: return single relation object
303
322
  const targetPks = getPrimaryKeys(targetCollection, registry!);
304
323
  const relatedEntity = relatedEntities[0] as Record<string, unknown>;
305
- result[propKey] = {
306
- id: buildCompositeId(relatedEntity, targetPks),
307
- path: targetCollection.slug,
308
- __type: "relation"
309
- };
324
+ result[propKey] = createRelationRef(buildCompositeId(relatedEntity, targetPks), targetCollection.slug);
310
325
  } else {
311
326
  // One-to-many: return array of relation objects
312
327
  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
- }));
328
+ result[propKey] = relatedEntities.map((entity: Record<string, unknown>) =>
329
+ createRelationRef(buildCompositeId(entity, targetPks), targetCollection.slug)
330
+ );
318
331
  }
319
332
  }
320
333
  }
@@ -403,21 +416,13 @@ export async function parseDataFromServer<M extends Record<string, any>>(
403
416
  if (relation.cardinality === "one") {
404
417
  // One-to-one: return single relation object
405
418
  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
- };
419
+ const targetEntity = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
420
+ result[propKey] = createRelationRef(buildCompositeId(targetEntity, targetPks), targetCollection.slug);
412
421
  } else {
413
422
  // One-to-many: return array of relation objects
414
423
  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
- };
424
+ const targetEntity = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
425
+ return createRelationRef(buildCompositeId(targetEntity, targetPks), targetCollection.slug);
421
426
  });
422
427
  }
423
428
  }
@@ -447,35 +452,48 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
447
452
  if (typeof value === "string" || typeof value === "number") {
448
453
  let relationDef: Relation | undefined = (property as RelationProperty).relation;
449
454
  if (!relationDef && propertyKey) {
450
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
451
- relationDef = resolvedRelations[propertyKey];
455
+ const resolvedRelations = resolveCollectionRelations(collection);
456
+ relationDef = findRelation(resolvedRelations, propertyKey);
452
457
  }
453
458
  if (!relationDef) {
454
- relationDef = (collection as import("@rebasepro/types").PostgresCollection<any, any>).relations?.find((rel) => rel.relationName === (property as RelationProperty).relationName);
459
+ relationDef = (collection as CollectionWithRelations).relations?.find((rel: Relation) => rel.relationName === (property as RelationProperty).relationName);
455
460
  }
456
-
461
+
457
462
  if (!relationDef) {
458
- console.warn(`Relation not defined in property for key: ${propertyKey || 'unknown'}`);
463
+ console.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
459
464
  return value;
460
465
  }
461
-
466
+
462
467
  try {
463
468
  const targetCollection = relationDef.target();
464
- return {
465
- id: value.toString(),
466
- path: targetCollection.slug,
467
- __type: "relation"
468
- };
469
+ return createRelationRef(value.toString(), targetCollection.slug);
469
470
  } catch (e) {
470
- console.warn(`Could not resolve target collection for relation property: ${propertyKey || 'unknown'}`, e);
471
+ console.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, e);
471
472
  return value;
472
473
  }
473
474
  }
474
475
  return value;
475
476
 
476
477
  case "array":
477
- if (Array.isArray(value) && property.of) {
478
- return value.map(item => parsePropertyFromServer(item, property.of as Property, collection));
478
+ if (Array.isArray(value)) {
479
+ if (property.of) {
480
+ return value.map(item => parsePropertyFromServer(item, property.of as Property, collection));
481
+ } else if (property.oneOf) {
482
+ const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
483
+ const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
484
+ return value.map((e) => {
485
+ if (e === null) return null;
486
+ if (typeof e !== "object") return e;
487
+ const rec = e as Record<string, unknown>;
488
+ const type = rec[typeField] as string;
489
+ const childProperty = property.oneOf?.properties[type];
490
+ if (!type || !childProperty) return e;
491
+ return {
492
+ [typeField]: type,
493
+ [valueField]: parsePropertyFromServer(rec[valueField], childProperty, collection)
494
+ };
495
+ });
496
+ }
479
497
  }
480
498
  return value;
481
499
 
@@ -526,25 +544,24 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
526
544
  }
527
545
 
528
546
  /**
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.
547
+ * Shared internal helper: normalizes scalar column values from a DB row
548
+ * into frontend format. Handles FK-column preservation, type coercion
549
+ * (dates, numbers, NaN), and property filtering.
533
550
  *
534
- * Use this instead of `parseDataFromServer` when processing results from
535
- * `db.query.findFirst/findMany` which return pre-hydrated relation data.
551
+ * @param skipRelations When `true`, relation-typed properties are omitted
552
+ * from the result (used by `normalizeDbValues` where
553
+ * Drizzle's relational API already hydrates them).
536
554
  */
537
- export function normalizeDbValues<M extends Record<string, any>>(
555
+ function normalizeScalarValues<M extends Record<string, unknown>>(
538
556
  data: M,
539
- collection: EntityCollection
540
- ): M {
541
- const properties = collection.properties;
542
- if (!data || !properties) return data;
543
-
557
+ properties: Properties,
558
+ collection: EntityCollection,
559
+ resolvedRelations: Record<string, Relation>,
560
+ options: { skipRelations: boolean }
561
+ ): Record<string, unknown> {
544
562
  const result: Record<string, unknown> = {};
545
563
 
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>);
564
+ // Identify FK columns used only for relations and not exposed as properties
548
565
  const internalFKColumns = new Set<string>();
549
566
  Object.values(resolvedRelations).forEach(relation => {
550
567
  if (relation.localKey && !properties[relation.localKey]) {
@@ -553,17 +570,39 @@ export function normalizeDbValues<M extends Record<string, any>>(
553
570
  });
554
571
 
555
572
  for (const [key, value] of Object.entries(data)) {
556
- // Skip internal FK columns
557
- if (internalFKColumns.has(key)) continue;
573
+ // Keep internal FK columns as primitives
574
+ if (internalFKColumns.has(key)) {
575
+ result[key] = value === null ? null : (typeof value === "number" ? value : String(value));
576
+ continue;
577
+ }
558
578
 
559
579
  const property = properties[key as keyof M] as Property;
560
580
  if (!property) continue; // Skip DB columns not defined in properties
561
581
 
562
- // Skip relation properties they're already handled by db.query
563
- if (property.type === "relation") continue;
582
+ if (options.skipRelations && property.type === "relation") continue;
564
583
 
565
584
  result[key] = parsePropertyFromServer(value, property, collection, key);
566
585
  }
567
586
 
568
- return result as M;
587
+ return result;
588
+ }
589
+
590
+ /**
591
+ * Lightweight value normalization for db.query results.
592
+ * Only handles type coercion (dates, numbers, NaN) and property filtering.
593
+ * Does NOT query the database for relations — those are already resolved
594
+ * by Drizzle's relational query API.
595
+ *
596
+ * Use this instead of `parseDataFromServer` when processing results from
597
+ * `db.query.findFirst/findMany` which return pre-hydrated relation data.
598
+ */
599
+ export function normalizeDbValues<M extends Record<string, unknown>>(
600
+ data: M,
601
+ collection: EntityCollection
602
+ ): M {
603
+ const properties = collection.properties;
604
+ if (!data || !properties) return data;
605
+
606
+ const resolvedRelations = resolveCollectionRelations(collection);
607
+ return normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: true }) as M;
569
608
  }
@@ -1,6 +1,6 @@
1
- import { Pool } from 'pg';
2
- import { drizzle } from 'drizzle-orm/node-postgres';
3
- import { NodePgDatabase } from 'drizzle-orm/node-postgres';
1
+ import { Pool } from "pg";
2
+ import { drizzle } from "drizzle-orm/node-postgres";
3
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
 
5
5
  export class DatabasePoolManager {
6
6
  private pools: Map<string, Pool> = new Map();
@@ -41,11 +41,12 @@ export class DatabasePoolManager {
41
41
  const pool = new Pool({
42
42
  connectionString: url.toString(),
43
43
  max: 10, // Default sensible limit, can be tuned later
44
- idleTimeoutMillis: 30000,
44
+ idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
45
+ allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
45
46
  });
46
47
 
47
48
  // Prevent idle client errors from crashing the Node.js process
48
- pool.on('error', (err) => {
49
+ pool.on("error", (err) => {
49
50
  console.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, err);
50
51
  });
51
52
 
@@ -50,7 +50,8 @@ export class HistoryService {
50
50
  private db: NodePgDatabase,
51
51
  retention?: Partial<HistoryRetentionConfig>
52
52
  ) {
53
- this.retention = { ...DEFAULT_RETENTION, ...retention };
53
+ this.retention = { ...DEFAULT_RETENTION,
54
+ ...retention };
54
55
  }
55
56
 
56
57
  /**
@@ -74,19 +75,9 @@ export class HistoryService {
74
75
  ? findChangedFields(previousValues, values)
75
76
  : null;
76
77
 
77
- try {
78
- require('fs').appendFileSync(
79
- '/Users/francesco/rebase/packages/backend/history_diff.log',
80
- `[recordHistory: ${tableName}/${entityId} - ${action}]\n` +
81
- `CHANGED FIELDS: ${JSON.stringify(changedFields)}\n` +
82
- `PREVIOUS: ${JSON.stringify(previousValues, null, 2)}\n` +
83
- `NEW: ${JSON.stringify(values, null, 2)}\n\n`
84
- );
85
- } catch (e) {
86
- console.error("DEBUG FILE WRITE ERROR:", e);
87
- }
88
78
 
89
79
  // Skip recording if this is an update with zero actual changes
80
+
90
81
  if (action === "update" && (!changedFields || changedFields.length === 0)) {
91
82
  return;
92
83
  }
package/src/interfaces.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Database Abstraction Interfaces
3
- *
3
+ *
4
4
  * These interfaces define the contracts that any database backend must implement
5
5
  * to be used with Rebase. This allows for pluggable database backends like
6
6
  * PostgreSQL, MongoDB, MySQL, etc.
7
7
  */
8
8
 
9
- import {
9
+ import {
10
10
  Entity,
11
11
  EntityCollection,
12
12
  FilterValues,
@@ -37,7 +37,7 @@ import { PgTransaction } from "drizzle-orm/pg-core";
37
37
  * Note: `any` is intentional here — it represents a Drizzle client with
38
38
  * a dynamic schema, enabling `db.query[tableName]` access without casts.
39
39
  */
40
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+
41
41
  export type DrizzleClient = NodePgDatabase<any> | PgTransaction<any, any, any>;
42
42
 
43
43
  export type {
@@ -16,8 +16,6 @@ export const users = rebaseSchema.table("users", {
16
16
  passwordHash: varchar("password_hash", { length: 255 }), // NULL for OAuth-only users
17
17
  displayName: varchar("display_name", { length: 255 }),
18
18
  photoUrl: varchar("photo_url", { length: 500 }),
19
- provider: varchar("provider", { length: 50 }).notNull().default("email"), // 'email' | 'google'
20
- googleId: varchar("google_id", { length: 255 }).unique(),
21
19
  emailVerified: boolean("email_verified").default(false).notNull(),
22
20
  emailVerificationToken: varchar("email_verification_token", { length: 255 }),
23
21
  emailVerificationSentAt: timestamp("email_verification_sent_at"),
@@ -99,11 +97,27 @@ export const appConfig = rebaseSchema.table("app_config", {
99
97
  updatedAt: timestamp("updated_at").defaultNow().notNull()
100
98
  });
101
99
 
100
+ /**
101
+ * User identities - maps external OAuth profiles back to local users
102
+ */
103
+ export const userIdentities = rebaseSchema.table("user_identities", {
104
+ id: uuid("id").defaultRandom().primaryKey(),
105
+ userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
106
+ provider: varchar("provider", { length: 50 }).notNull(), // e.g. 'google', 'linkedin'
107
+ providerId: varchar("provider_id", { length: 255 }).notNull(),
108
+ profileData: jsonb("profile_data"),
109
+ createdAt: timestamp("created_at").defaultNow().notNull(),
110
+ updatedAt: timestamp("updated_at").defaultNow().notNull()
111
+ }, (table) => ({
112
+ uniqueProviderId: unique("unique_provider_id").on(table.provider, table.providerId)
113
+ }));
114
+
102
115
  // Relations
103
116
  export const usersRelations = relations(users, ({ many }) => ({
104
117
  userRoles: many(userRoles),
105
118
  refreshTokens: many(refreshTokens),
106
- passwordResetTokens: many(passwordResetTokens)
119
+ passwordResetTokens: many(passwordResetTokens),
120
+ userIdentities: many(userIdentities)
107
121
  }));
108
122
 
109
123
  export const rolesRelations = relations(roles, ({ many }) => ({
@@ -135,6 +149,13 @@ export const passwordResetTokensRelations = relations(passwordResetTokens, ({ on
135
149
  })
136
150
  }));
137
151
 
152
+ export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
153
+ user: one(users, {
154
+ fields: [userIdentities.userId],
155
+ references: [users.id]
156
+ })
157
+ }));
158
+
138
159
  // Type exports
139
160
  export type User = typeof users.$inferSelect;
140
161
  export type NewUser = typeof users.$inferInsert;
@@ -144,3 +165,5 @@ export type UserRole = typeof userRoles.$inferSelect;
144
165
  export type RefreshToken = typeof refreshTokens.$inferSelect;
145
166
  export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
146
167
  export type AppConfig = typeof appConfig.$inferSelect;
168
+ export type UserIdentity = typeof userIdentities.$inferSelect;
169
+ export type NewUserIdentity = typeof userIdentities.$inferInsert;
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point for `rebase doctor`.
4
+ * Invoked via tsx by the server-postgresql CLI plugin.
5
+ */
6
+ import path from "path";
7
+ import chalk from "chalk";
8
+ import { runDoctor } from "./doctor";
9
+
10
+ async function main() {
11
+ const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
12
+ const schemaArg = process.argv.find((a) => a.startsWith("--schema="));
13
+
14
+ const collectionsPath = collectionsArg?.split("=")[1] ?? path.join("..", "shared", "collections");
15
+ const schemaPath = schemaArg?.split("=")[1] ?? path.join("src", "schema.generated.ts");
16
+
17
+ // Load .env
18
+ try {
19
+ const dotenv = await import("dotenv");
20
+ const envPath = process.env.DOTENV_CONFIG_PATH;
21
+ if (envPath) {
22
+ dotenv.config({ path: envPath });
23
+ } else {
24
+ dotenv.config();
25
+ }
26
+ } catch {
27
+ // dotenv may not be installed
28
+ }
29
+
30
+ const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
31
+
32
+ const report = await runDoctor({
33
+ collectionsPath: path.resolve(process.cwd(), collectionsPath),
34
+ schemaPath: path.resolve(process.cwd(), schemaPath),
35
+ databaseUrl: databaseUrl ?? undefined
36
+ });
37
+
38
+ // Exit with non-zero code if there are errors
39
+ if (report.summary.errors > 0) {
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ main().catch((err) => {
45
+ console.error(chalk.red(" ✗ Doctor failed:"), err instanceof Error ? err.message : String(err));
46
+ process.exit(1);
47
+ });