@rebasepro/server-postgresql 0.0.1-canary.09e5ec5

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 (196) 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 +56 -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 +58 -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 +22 -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 +11298 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +11306 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -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 +192 -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 +40 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +58 -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 +868 -0
  43. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  45. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  46. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  47. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
  48. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  49. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  51. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
  52. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  53. package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
  54. package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
  55. package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
  56. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  57. package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
  58. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  59. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  60. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  61. package/dist/types/src/controllers/auth.d.ts +119 -0
  62. package/dist/types/src/controllers/client.d.ts +170 -0
  63. package/dist/types/src/controllers/collection_registry.d.ts +45 -0
  64. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  65. package/dist/types/src/controllers/data.d.ts +168 -0
  66. package/dist/types/src/controllers/data_driver.d.ts +160 -0
  67. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  68. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  69. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  70. package/dist/types/src/controllers/email.d.ts +34 -0
  71. package/dist/types/src/controllers/index.d.ts +18 -0
  72. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  73. package/dist/types/src/controllers/navigation.d.ts +213 -0
  74. package/dist/types/src/controllers/registry.d.ts +54 -0
  75. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  76. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  77. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  78. package/dist/types/src/controllers/storage.d.ts +171 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/rebase_context.d.ts +105 -0
  81. package/dist/types/src/types/backend.d.ts +536 -0
  82. package/dist/types/src/types/builders.d.ts +15 -0
  83. package/dist/types/src/types/chips.d.ts +5 -0
  84. package/dist/types/src/types/collections.d.ts +856 -0
  85. package/dist/types/src/types/cron.d.ts +102 -0
  86. package/dist/types/src/types/data_source.d.ts +64 -0
  87. package/dist/types/src/types/entities.d.ts +145 -0
  88. package/dist/types/src/types/entity_actions.d.ts +98 -0
  89. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  90. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  91. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  92. package/dist/types/src/types/entity_views.d.ts +61 -0
  93. package/dist/types/src/types/export_import.d.ts +21 -0
  94. package/dist/types/src/types/index.d.ts +23 -0
  95. package/dist/types/src/types/locales.d.ts +4 -0
  96. package/dist/types/src/types/modify_collections.d.ts +5 -0
  97. package/dist/types/src/types/plugins.d.ts +279 -0
  98. package/dist/types/src/types/properties.d.ts +1176 -0
  99. package/dist/types/src/types/property_config.d.ts +70 -0
  100. package/dist/types/src/types/relations.d.ts +336 -0
  101. package/dist/types/src/types/slots.d.ts +252 -0
  102. package/dist/types/src/types/translations.d.ts +870 -0
  103. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  104. package/dist/types/src/types/websockets.d.ts +78 -0
  105. package/dist/types/src/users/index.d.ts +2 -0
  106. package/dist/types/src/users/roles.d.ts +22 -0
  107. package/dist/types/src/users/user.d.ts +46 -0
  108. package/drizzle-test/0000_woozy_junta.sql +6 -0
  109. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  110. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  111. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  112. package/drizzle-test/meta/0000_snapshot.json +47 -0
  113. package/drizzle-test/meta/0001_snapshot.json +48 -0
  114. package/drizzle-test/meta/0002_snapshot.json +38 -0
  115. package/drizzle-test/meta/0003_snapshot.json +48 -0
  116. package/drizzle-test/meta/_journal.json +34 -0
  117. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  118. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  119. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  120. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  121. package/drizzle-test-out/meta/_journal.json +20 -0
  122. package/drizzle.test.config.ts +10 -0
  123. package/jest-all.log +3128 -0
  124. package/jest.log +49 -0
  125. package/package.json +92 -0
  126. package/scratch.ts +41 -0
  127. package/src/PostgresBackendDriver.ts +1008 -0
  128. package/src/PostgresBootstrapper.ts +231 -0
  129. package/src/auth/ensure-tables.ts +381 -0
  130. package/src/auth/services.ts +799 -0
  131. package/src/cli.ts +648 -0
  132. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  133. package/src/connection.ts +84 -0
  134. package/src/data-transformer.ts +608 -0
  135. package/src/databasePoolManager.ts +85 -0
  136. package/src/history/HistoryService.ts +248 -0
  137. package/src/history/ensure-history-table.ts +45 -0
  138. package/src/index.ts +13 -0
  139. package/src/interfaces.ts +60 -0
  140. package/src/schema/auth-schema.ts +169 -0
  141. package/src/schema/doctor-cli.ts +47 -0
  142. package/src/schema/doctor.ts +595 -0
  143. package/src/schema/generate-drizzle-schema-logic.ts +765 -0
  144. package/src/schema/generate-drizzle-schema.ts +151 -0
  145. package/src/schema/introspect-db-logic.ts +542 -0
  146. package/src/schema/introspect-db.ts +211 -0
  147. package/src/schema/test-schema.ts +11 -0
  148. package/src/services/BranchService.ts +237 -0
  149. package/src/services/EntityFetchService.ts +1576 -0
  150. package/src/services/EntityPersistService.ts +349 -0
  151. package/src/services/RelationService.ts +1274 -0
  152. package/src/services/entity-helpers.ts +147 -0
  153. package/src/services/entityService.ts +211 -0
  154. package/src/services/index.ts +13 -0
  155. package/src/services/realtimeService.ts +1034 -0
  156. package/src/utils/drizzle-conditions.ts +1000 -0
  157. package/src/websocket.ts +518 -0
  158. package/test/auth-services.test.ts +661 -0
  159. package/test/batch-many-to-many-regression.test.ts +573 -0
  160. package/test/branchService.test.ts +367 -0
  161. package/test/data-transformer-hardening.test.ts +417 -0
  162. package/test/data-transformer.test.ts +175 -0
  163. package/test/doctor.test.ts +182 -0
  164. package/test/drizzle-conditions.test.ts +895 -0
  165. package/test/entityService.errors.test.ts +367 -0
  166. package/test/entityService.relations.test.ts +1008 -0
  167. package/test/entityService.subcollection-search.test.ts +566 -0
  168. package/test/entityService.test.ts +1035 -0
  169. package/test/generate-drizzle-schema.test.ts +988 -0
  170. package/test/historyService.test.ts +141 -0
  171. package/test/introspect-db-generation.test.ts +436 -0
  172. package/test/introspect-db-utils.test.ts +389 -0
  173. package/test/n-plus-one-regression.test.ts +314 -0
  174. package/test/postgresDataDriver.test.ts +648 -0
  175. package/test/realtimeService.test.ts +307 -0
  176. package/test/relation-pipeline-gaps.test.ts +637 -0
  177. package/test/relations.test.ts +1115 -0
  178. package/test/unmapped-tables-safety.test.ts +345 -0
  179. package/test-drizzle-bug.ts +18 -0
  180. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  181. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  182. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  183. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  184. package/test-drizzle-out/meta/_journal.json +20 -0
  185. package/test-drizzle-prompt.sh +2 -0
  186. package/test-policy-prompt.sh +3 -0
  187. package/test-programmatic.ts +30 -0
  188. package/test-programmatic2.ts +59 -0
  189. package/test-schema-no-policies.ts +12 -0
  190. package/test_drizzle_mock.js +3 -0
  191. package/test_find_changed.mjs +32 -0
  192. package/test_hash.js +14 -0
  193. package/test_output.txt +3145 -0
  194. package/tsconfig.json +49 -0
  195. package/tsconfig.prod.json +20 -0
  196. package/vite.config.ts +82 -0
@@ -0,0 +1,349 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { AnyPgColumn } from "drizzle-orm/pg-core";
3
+ // import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
+ import { Entity, EntityCollection, Properties, Relation } from "@rebasepro/types";
5
+ import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
+ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
+ import {
8
+ getCollectionByPath,
9
+ getTableForCollection,
10
+ getPrimaryKeys,
11
+ parseIdValues,
12
+ buildCompositeId
13
+ } from "./entity-helpers";
14
+ import { sanitizeAndConvertDates, serializeDataToServer } from "../data-transformer";
15
+ import { RelationService } from "./RelationService";
16
+ import { EntityFetchService } from "./EntityFetchService";
17
+ import { DrizzleClient } from "../interfaces";
18
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
19
+
20
+ /**
21
+ * Service for handling all entity write operations.
22
+ * Handles saving, deleting, and updating entities.
23
+ */
24
+ export class EntityPersistService {
25
+ private relationService: RelationService;
26
+ private fetchService: EntityFetchService;
27
+
28
+ constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
29
+ this.relationService = new RelationService(db, registry);
30
+ this.fetchService = new EntityFetchService(db, registry);
31
+ }
32
+
33
+
34
+ /**
35
+ * Delete an entity by ID
36
+ */
37
+ async deleteEntity(collectionPath: string, entityId: string | number, _databaseId?: string): Promise<void> {
38
+ const collection = getCollectionByPath(collectionPath, this.registry);
39
+ const table = getTableForCollection(collection, this.registry);
40
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
41
+ const idInfo = idInfoArray[0];
42
+ const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
43
+
44
+ if (!idField) {
45
+ throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
46
+ }
47
+
48
+ const parsedIdObj = parseIdValues(entityId, idInfoArray);
49
+ const parsedId = parsedIdObj[idInfo.fieldName];
50
+
51
+ await this.db
52
+ .delete(table)
53
+ .where(eq(idField, parsedId));
54
+ }
55
+
56
+ /**
57
+ * Save an entity (create or update)
58
+ */
59
+ async saveEntity<M extends Record<string, unknown>>(
60
+ collectionPath: string,
61
+ values: Partial<M>,
62
+ entityId?: string | number,
63
+ databaseId?: string
64
+ ): Promise<Entity<M>> {
65
+ // If saving under a nested relation path, resolve the parent and inject FK
66
+ let effectiveCollectionPath = collectionPath;
67
+ const effectiveValues: Partial<M> = { ...values };
68
+ let junctionTableInfo: { parentCollection: EntityCollection; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
69
+
70
+ if (collectionPath.includes("/")) {
71
+ const segments = collectionPath.split("/").filter(Boolean);
72
+ if (segments.length >= 3 && segments.length % 2 === 1) {
73
+ const rootSegment = segments[0];
74
+ let currentCollection = getCollectionByPath(rootSegment, this.registry);
75
+ let currentEntityId: string | number = segments[1];
76
+
77
+ for (let i = 2; i < segments.length; i += 2) {
78
+ const relationKey = segments[i];
79
+ const resolvedRelations = resolveCollectionRelations(currentCollection);
80
+ const relation = findRelation(resolvedRelations, relationKey);
81
+
82
+ if (!relation) {
83
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
84
+ throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'. Available relations: [${available}]`);
85
+ }
86
+
87
+ if (i === segments.length - 1) {
88
+ const targetCollection = relation.target();
89
+ effectiveCollectionPath = targetCollection.slug;
90
+
91
+ // Handle many-to-many with junction table
92
+ if (relation.cardinality === "many" && relation.through) {
93
+ const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
94
+ const parentIdInfo = parentIdInfoArray[0];
95
+ const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
96
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
97
+
98
+ junctionTableInfo = {
99
+ parentCollection: currentCollection,
100
+ parentId: parsedParentId,
101
+ relation: relation,
102
+ relationKey: relationKey
103
+ };
104
+ break;
105
+ }
106
+
107
+ // Find the FK column that should store the parent ID
108
+ let targetColumnName: string;
109
+
110
+ if (relation.localKey) {
111
+ targetColumnName = relation.localKey;
112
+ } else if (relation.foreignKeyOnTarget) {
113
+ targetColumnName = relation.foreignKeyOnTarget;
114
+ } else if (relation.joinPath && relation.joinPath.length > 0) {
115
+ const targetTableName = getTableName(targetCollection);
116
+ const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
117
+
118
+ if (relevantJoinStep) {
119
+ const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to);
120
+ targetColumnName = targetColumnNames[0];
121
+ } else {
122
+ console.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
123
+ const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
124
+ targetColumnName = targetColumnNames[0];
125
+ }
126
+ } else {
127
+ throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
128
+ }
129
+
130
+ const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
131
+ const parentIdInfo = parentIdInfoArray[0];
132
+ const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
133
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
134
+
135
+ const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
136
+ if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
137
+ console.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
138
+ }
139
+ (effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
140
+ break;
141
+ } else {
142
+ const nextEntityId = segments[i + 1];
143
+ currentCollection = relation.target();
144
+ currentEntityId = nextEntityId;
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ const collection = getCollectionByPath(effectiveCollectionPath, this.registry);
151
+ const table = getTableForCollection(collection, this.registry);
152
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
153
+ const primaryKeyFields = idInfoArray.map(info => info.fieldName);
154
+
155
+ // Build an object mapping required for dynamic returning
156
+ const returningKeys: Record<string, AnyPgColumn> = {};
157
+ idInfoArray.forEach(info => {
158
+ const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
159
+ if (!field) throw new Error(`Primary key field '${info.fieldName}' not found in table for collection '${effectiveCollectionPath}'`);
160
+ returningKeys[info.fieldName] = field;
161
+ });
162
+
163
+ // Separate relations that require special handling
164
+ const relationValues: Record<string, unknown> = {};
165
+ const otherValues: Partial<M> = { ...effectiveValues };
166
+ const resolvedRelations = resolveCollectionRelations(collection);
167
+
168
+ for (const key in resolvedRelations) {
169
+ const relation = resolvedRelations[key];
170
+ if (relation && relation.cardinality === "many") {
171
+ if (Object.prototype.hasOwnProperty.call(otherValues, key)) {
172
+ relationValues[key] = otherValues[key as keyof M];
173
+ delete otherValues[key as keyof M];
174
+ }
175
+ }
176
+ }
177
+
178
+ // Transform relations to IDs, then sanitize
179
+ const serializedResult = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
180
+
181
+ // Extract relation updates from the typed result
182
+ const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
183
+ const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
184
+
185
+ const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
186
+
187
+ let savedId: string | number;
188
+ try {
189
+ savedId = await this.db.transaction(async (tx) => {
190
+ let currentId: string | number;
191
+
192
+ if (entityId) {
193
+ // Update existing entity
194
+ currentId = entityId; // `entityId` is already the formatted composite or singular string
195
+ const idValues = parseIdValues(entityId, idInfoArray);
196
+
197
+ // Apply joinPath one-to-one relation updates BEFORE the main UPDATE.
198
+ // This ensures parentSourceCol reads the pre-update FK value, preventing
199
+ // stale joinPath values from corrupting related entities when an
200
+ // intermediate FK (e.g., author_id) changes in the same save.
201
+ // Example: changing author A→B with stale profile P1 (A's):
202
+ // reads old author_id=A → clears P1.author_id → re-sets P1.author_id=A (no-op).
203
+ if (joinPathRelationUpdates.length > 0) {
204
+ await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
205
+ }
206
+
207
+ // Only issue an UPDATE if there are scalar columns to set.
208
+ // When the payload contains only relation data, entityData is
209
+ // empty after relation stripping and Drizzle throws "No values to set".
210
+ const scalarKeys = Object.keys(entityData as Record<string, unknown>);
211
+ if (scalarKeys.length > 0) {
212
+ const updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
213
+ const conditions = [];
214
+ for (const info of idInfoArray) {
215
+ const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
216
+ conditions.push(eq(field, idValues[info.fieldName]));
217
+ }
218
+
219
+ await updateQuery.where(and(...conditions));
220
+ }
221
+ } else {
222
+ const dataForInsert = { ...(entityData as Record<string, unknown>) };
223
+
224
+ // Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
225
+ for (const info of idInfoArray) {
226
+ if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
227
+ delete dataForInsert[info.fieldName];
228
+ }
229
+ }
230
+
231
+ const result = await tx
232
+ .insert(table)
233
+ .values(dataForInsert)
234
+ .returning(returningKeys);
235
+
236
+ const resultRow = result[0];
237
+ currentId = buildCompositeId(resultRow, idInfoArray);
238
+
239
+ // For inserts, apply joinPath after since the parent row didn't exist before
240
+ if (joinPathRelationUpdates.length > 0) {
241
+ await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
242
+ }
243
+ }
244
+
245
+ // Handle inverse relation updates
246
+ if (inverseRelationUpdates.length > 0) {
247
+ await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
248
+ }
249
+
250
+ // Update many-to-many relations
251
+ if (Object.keys(relationValues).length > 0) {
252
+ await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
253
+ }
254
+
255
+ // Handle junction table creation for many-to-many path-based saves
256
+ if (junctionTableInfo && !entityId) {
257
+ await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
258
+ }
259
+
260
+ return currentId;
261
+ });
262
+ } catch (error: unknown) {
263
+ throw this.toUserFriendlyError(error, collection.slug);
264
+ }
265
+
266
+ // Fetch the updated/created entity to return with proper relation objects
267
+ const finalEntity = await this.fetchService.fetchEntity<M>(collection.slug, savedId, databaseId);
268
+ if (!finalEntity) throw new Error("Could not fetch entity after save.");
269
+ return finalEntity;
270
+ }
271
+
272
+ /**
273
+ * Get the RelationService instance for external use
274
+ */
275
+ getRelationService(): RelationService {
276
+ return this.relationService;
277
+ }
278
+
279
+ /**
280
+ * Get the FetchService instance for external use
281
+ */
282
+ getFetchService(): EntityFetchService {
283
+ return this.fetchService;
284
+ }
285
+
286
+ /**
287
+ * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
288
+ */
289
+ private toUserFriendlyError(error: unknown, collectionSlug: string): Error {
290
+ // Dig into Drizzle's wrapper to find the underlying PG error
291
+ const pgError = this.extractPgError(error);
292
+
293
+ if (pgError) {
294
+ const detail = pgError.detail as string | undefined;
295
+ const constraint = pgError.constraint as string | undefined;
296
+ const column = pgError.column as string | undefined;
297
+ const table = pgError.table as string | undefined;
298
+
299
+ switch (pgError.code) {
300
+ case "23503": // foreign_key_violation
301
+ return new Error(
302
+ detail
303
+ ? `Foreign key constraint violated: ${detail}`
304
+ : `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
305
+ );
306
+ case "23505": // unique_violation
307
+ return new Error(
308
+ detail
309
+ ? `Duplicate value: ${detail}`
310
+ : `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
311
+ );
312
+ case "23502": // not_null_violation
313
+ return new Error(
314
+ `Missing required field: "${column ?? "unknown"}" in "${table ?? collectionSlug}" cannot be empty.`
315
+ );
316
+ case "23514": // check_violation
317
+ return new Error(
318
+ `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
319
+ );
320
+ }
321
+ }
322
+
323
+ // Fall through: re-throw original
324
+ if (error instanceof Error) return error;
325
+ return new Error(String(error));
326
+ }
327
+
328
+ /**
329
+ * Extract the underlying PostgreSQL error from a Drizzle wrapper.
330
+ * Drizzle wraps PG errors in a `cause` property.
331
+ */
332
+ private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown }) | null {
333
+ if (!error || typeof error !== "object") return null;
334
+
335
+ const err = error as Error & { code?: string; cause?: unknown; detail?: unknown };
336
+
337
+ // Check if the error itself has a PG error code
338
+ if (err.code && /^[0-9]{5}$/.test(err.code)) {
339
+ return err as Error & { code: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown };
340
+ }
341
+
342
+ // Check the cause chain (Drizzle wraps PG errors)
343
+ if (err.cause && typeof err.cause === "object") {
344
+ return this.extractPgError(err.cause);
345
+ }
346
+
347
+ return null;
348
+ }
349
+ }