@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,351 @@
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 } 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, any>>(
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
+
69
+ if (collectionPath.includes("/")) {
70
+ const segments = collectionPath.split("/").filter(Boolean);
71
+ if (segments.length >= 3 && segments.length % 2 === 1) {
72
+ const rootSegment = segments[0];
73
+ let currentCollection = getCollectionByPath(rootSegment, this.registry);
74
+ let currentEntityId: string | number = segments[1];
75
+
76
+ for (let i = 2; i < segments.length; i += 2) {
77
+ const relationKey = segments[i];
78
+ const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
79
+ const relation = resolvedRelations[relationKey];
80
+
81
+ if (!relation) {
82
+ throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
83
+ }
84
+
85
+ if (i === segments.length - 1) {
86
+ const targetCollection = relation.target();
87
+ effectiveCollectionPath = targetCollection.slug;
88
+
89
+ // Handle many-to-many with junction table
90
+ if (relation.cardinality === "many" && relation.through) {
91
+ const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
92
+ const parentIdInfo = parentIdInfoArray[0];
93
+ const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
94
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
95
+
96
+ (effectiveValues as Record<string, unknown>).__junction_table_info = {
97
+ parentCollection: currentCollection,
98
+ parentId: parsedParentId,
99
+ relation: relation,
100
+ relationKey: relationKey
101
+ };
102
+ break;
103
+ }
104
+
105
+ // Find the FK column that should store the parent ID
106
+ let targetColumnName: string;
107
+
108
+ if (relation.localKey) {
109
+ targetColumnName = relation.localKey;
110
+ } else if (relation.foreignKeyOnTarget) {
111
+ targetColumnName = relation.foreignKeyOnTarget;
112
+ } else if (relation.joinPath && relation.joinPath.length > 0) {
113
+ const targetTableName = getTableName(targetCollection);
114
+ const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
115
+
116
+ if (relevantJoinStep) {
117
+ const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to);
118
+ targetColumnName = targetColumnNames[0];
119
+ } else {
120
+ console.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
121
+ const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
122
+ targetColumnName = targetColumnNames[0];
123
+ }
124
+ } else {
125
+ throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
126
+ }
127
+
128
+ const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
129
+ const parentIdInfo = parentIdInfoArray[0];
130
+ const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
131
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
132
+
133
+ const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
134
+ if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
135
+ console.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
136
+ }
137
+ (effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
138
+ break;
139
+ } else {
140
+ const nextEntityId = segments[i + 1];
141
+ currentCollection = relation.target();
142
+ currentEntityId = nextEntityId;
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ const collection = getCollectionByPath(effectiveCollectionPath, this.registry);
149
+ const table = getTableForCollection(collection, this.registry);
150
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
151
+ const primaryKeyFields = idInfoArray.map(info => info.fieldName);
152
+
153
+ // Build an object mapping required for dynamic returning
154
+ const returningKeys: Record<string, AnyPgColumn> = {};
155
+ idInfoArray.forEach(info => {
156
+ const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
157
+ if (!field) throw new Error(`Primary key field '${info.fieldName}' not found in table for collection '${effectiveCollectionPath}'`);
158
+ returningKeys[info.fieldName] = field;
159
+ });
160
+
161
+ // Separate relations that require special handling
162
+ const relationValues: Record<string, unknown> = {};
163
+ const otherValues: Partial<M> = { ...effectiveValues };
164
+ const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
165
+
166
+ for (const key in resolvedRelations) {
167
+ const relation = resolvedRelations[key];
168
+ if (relation && relation.cardinality === "many") {
169
+ if (Object.prototype.hasOwnProperty.call(otherValues, key)) {
170
+ relationValues[key] = otherValues[key as keyof M];
171
+ delete otherValues[key as keyof M];
172
+ }
173
+ }
174
+ }
175
+
176
+ // Transform relations to IDs, then sanitize
177
+ const processedData = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
178
+
179
+ // Extract relation updates before sanitizing
180
+ const inverseRelationUpdates = ((processedData as Record<string, unknown>).__inverseRelationUpdates as Array<{ relationKey: string; relation: Relation; newValue: unknown; currentEntityId?: string | number; }>) || [];
181
+ const joinPathRelationUpdates = ((processedData as Record<string, unknown>).__joinPathRelationUpdates as Array<{ relationKey: string; relation: Relation; newTargetId: string | number | null; }>) || [];
182
+ const junctionTableInfo = (processedData as Record<string, unknown>).__junction_table_info as { parentCollection: EntityCollection<any, any>; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
183
+ delete (processedData as Record<string, unknown>).__inverseRelationUpdates;
184
+ delete (processedData as Record<string, unknown>).__joinPathRelationUpdates;
185
+ delete (processedData as Record<string, unknown>).__junction_table_info;
186
+
187
+ const entityData = sanitizeAndConvertDates(processedData);
188
+
189
+ let savedId: string | number;
190
+ try {
191
+ savedId = await this.db.transaction(async (tx) => {
192
+ let currentId: string | number;
193
+
194
+ if (entityId) {
195
+ // Update existing entity
196
+ currentId = entityId; // `entityId` is already the formatted composite or singular string
197
+ const idValues = parseIdValues(entityId, idInfoArray);
198
+
199
+ // Apply joinPath one-to-one relation updates BEFORE the main UPDATE.
200
+ // This ensures parentSourceCol reads the pre-update FK value, preventing
201
+ // stale joinPath values from corrupting related entities when an
202
+ // intermediate FK (e.g., author_id) changes in the same save.
203
+ // Example: changing author A→B with stale profile P1 (A's):
204
+ // reads old author_id=A → clears P1.author_id → re-sets P1.author_id=A (no-op).
205
+ if (joinPathRelationUpdates.length > 0) {
206
+ await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
207
+ }
208
+
209
+ // Only issue an UPDATE if there are scalar columns to set.
210
+ // When the payload contains only relation data, entityData is
211
+ // empty after relation stripping and Drizzle throws "No values to set".
212
+ const scalarKeys = Object.keys(entityData as Record<string, unknown>);
213
+ if (scalarKeys.length > 0) {
214
+ let updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
215
+ const conditions = [];
216
+ for (const info of idInfoArray) {
217
+ const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
218
+ conditions.push(eq(field, idValues[info.fieldName]));
219
+ }
220
+
221
+ await updateQuery.where(and(...conditions));
222
+ }
223
+ } else {
224
+ const dataForInsert = { ...(entityData as Record<string, unknown>) };
225
+
226
+ // Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
227
+ for (const info of idInfoArray) {
228
+ if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
229
+ delete dataForInsert[info.fieldName];
230
+ }
231
+ }
232
+
233
+ const result = await tx
234
+ .insert(table)
235
+ .values(dataForInsert)
236
+ .returning(returningKeys);
237
+
238
+ const resultRow = result[0];
239
+ currentId = buildCompositeId(resultRow, idInfoArray);
240
+
241
+ // For inserts, apply joinPath after since the parent row didn't exist before
242
+ if (joinPathRelationUpdates.length > 0) {
243
+ await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
244
+ }
245
+ }
246
+
247
+ // Handle inverse relation updates
248
+ if (inverseRelationUpdates.length > 0) {
249
+ await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
250
+ }
251
+
252
+ // Update many-to-many relations
253
+ if (Object.keys(relationValues).length > 0) {
254
+ await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
255
+ }
256
+
257
+ // Handle junction table creation for many-to-many path-based saves
258
+ if (junctionTableInfo && !entityId) {
259
+ await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
260
+ }
261
+
262
+ return currentId;
263
+ });
264
+ } catch (error: unknown) {
265
+ throw this.toUserFriendlyError(error, collection.slug);
266
+ }
267
+
268
+ // Fetch the updated/created entity to return with proper relation objects
269
+ const finalEntity = await this.fetchService.fetchEntity<M>(collection.slug, savedId, databaseId);
270
+ if (!finalEntity) throw new Error("Could not fetch entity after save.");
271
+ return finalEntity;
272
+ }
273
+
274
+ /**
275
+ * Get the RelationService instance for external use
276
+ */
277
+ getRelationService(): RelationService {
278
+ return this.relationService;
279
+ }
280
+
281
+ /**
282
+ * Get the FetchService instance for external use
283
+ */
284
+ getFetchService(): EntityFetchService {
285
+ return this.fetchService;
286
+ }
287
+
288
+ /**
289
+ * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
290
+ */
291
+ private toUserFriendlyError(error: unknown, collectionSlug: string): Error {
292
+ // Dig into Drizzle's wrapper to find the underlying PG error
293
+ const pgError = this.extractPgError(error);
294
+
295
+ if (pgError) {
296
+ const detail = pgError.detail as string | undefined;
297
+ const constraint = pgError.constraint as string | undefined;
298
+ const column = pgError.column as string | undefined;
299
+ const table = pgError.table as string | undefined;
300
+
301
+ switch (pgError.code) {
302
+ case "23503": // foreign_key_violation
303
+ return new Error(
304
+ detail
305
+ ? `Foreign key constraint violated: ${detail}`
306
+ : `Cannot save: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
307
+ );
308
+ case "23505": // unique_violation
309
+ return new Error(
310
+ detail
311
+ ? `Duplicate value: ${detail}`
312
+ : `Cannot save: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
313
+ );
314
+ case "23502": // not_null_violation
315
+ return new Error(
316
+ `Missing required field: "${column ?? "unknown"}" in "${table ?? collectionSlug}" cannot be empty.`
317
+ );
318
+ case "23514": // check_violation
319
+ return new Error(
320
+ `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${collectionSlug}".`
321
+ );
322
+ }
323
+ }
324
+
325
+ // Fall through: re-throw original
326
+ if (error instanceof Error) return error;
327
+ return new Error(String(error));
328
+ }
329
+
330
+ /**
331
+ * Extract the underlying PostgreSQL error from a Drizzle wrapper.
332
+ * Drizzle wraps PG errors in a `cause` property.
333
+ */
334
+ private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown }) | null {
335
+ if (!error || typeof error !== "object") return null;
336
+
337
+ const err = error as Error & { code?: string; cause?: unknown; detail?: unknown };
338
+
339
+ // Check if the error itself has a PG error code
340
+ if (err.code && /^[0-9]{5}$/.test(err.code)) {
341
+ return err as Error & { code: string; detail?: unknown; constraint?: unknown; column?: unknown; table?: unknown };
342
+ }
343
+
344
+ // Check the cause chain (Drizzle wraps PG errors)
345
+ if (err.cause && typeof err.cause === "object") {
346
+ return this.extractPgError(err.cause);
347
+ }
348
+
349
+ return null;
350
+ }
351
+ }