@rebasepro/server-postgres 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,557 +0,0 @@
1
- import { eq, and, sql, SQL } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- // import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
- import { CollectionConfig, Properties, Property, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations, fieldKeyForColumn } from "@rebasepro/common";
6
- import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
- import {
8
- assertWritableColumns,
9
- getCollectionByPath,
10
- getTableForCollection,
11
- getPrimaryKeys,
12
- parseIdValues,
13
- buildCompositeId
14
- } from "./collection-helpers";
15
- import { sanitizeAndConvertDates, serializeDataToServer } from "../data-transformer";
16
- import { RelationService } from "./RelationService";
17
- import { RelationWriteService } from "./RelationWriteService";
18
- import { FetchService } from "./FetchService";
19
- import { DrizzleClient } from "../interfaces";
20
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
21
- import {
22
- assertWritableThrough,
23
- isJunctionBackedRelation,
24
- isNestedPath,
25
- resolveNestedPath,
26
- type NestedPathHop
27
- } from "./nested-path";
28
- import { ApiError, logger } from "@rebasepro/server";
29
- import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage, isRowLevelSecurityDenial } from "../utils/pg-error-utils";
30
- import { explainZeroRowWrite } from "./write-denial";
31
-
32
- /**
33
- * Service for handling all row write operations.
34
- * Handles saving, deleting, and updating rows.
35
- */
36
- export class PersistService {
37
- /** Reads: whether a row is under a parent, the key a link joins on. */
38
- private relationService: RelationService;
39
- /** Writes: junction membership, foreign-key stamping, links. */
40
- private relationWrites: RelationWriteService;
41
- private fetchService: FetchService;
42
-
43
- constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
44
- this.relationService = new RelationService(db, registry);
45
- this.relationWrites = new RelationWriteService(db, registry);
46
- this.fetchService = new FetchService(db, registry);
47
- }
48
-
49
-
50
- /**
51
- * Explain a row write that matched nothing — see {@link explainZeroRowWrite}
52
- * for why a zero-row write cannot be reported as success.
53
- */
54
- private explainZeroRowWrite(
55
- handle: DrizzleClient,
56
- table: PgTable,
57
- conditions: SQL[],
58
- collectionPath: string,
59
- id: string | number,
60
- operation: "update" | "delete"
61
- ): Promise<ApiError> {
62
- return explainZeroRowWrite(
63
- handle,
64
- table,
65
- conditions,
66
- `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`,
67
- `No row "${id}" in "${collectionPath}" to ${operation}.`
68
- );
69
- }
70
-
71
- /**
72
- * Delete an row by ID
73
- */
74
- async delete(collectionPath: string, id: string | number, _databaseId?: string): Promise<void> {
75
- // A nested address deletes *through* a relation, and what that removes
76
- // depends on who owns the target row.
77
- const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
78
- if (hop) {
79
- assertWritableThrough(hop, collectionPath);
80
-
81
- if (!await this.relationService.isRelated(hop, id)) {
82
- throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
83
- }
84
-
85
- if (isJunctionBackedRelation(hop.relation)) {
86
- // Shared target: drop the link, not the row.
87
- if (!isManyToMany(hop.relation)) {
88
- throw ApiError.badRequest(
89
- `"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, ` +
90
- `so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" ` +
91
- "directly if that is what you meant.",
92
- "RELATION_NOT_UNLINKABLE"
93
- );
94
- }
95
- await this.relationWrites.unlinkRelatedEntity(this.db, hop, id);
96
- return;
97
- }
98
- // Owned child (inverse FK): deleting the row is the right meaning,
99
- // and membership above has established it is this parent's child.
100
- }
101
-
102
- const collection = getCollectionByPath(collectionPath, this.registry);
103
- const table = getTableForCollection(collection, this.registry);
104
- const idInfoArray = getPrimaryKeys(collection, this.registry);
105
-
106
- const parsedIdObj = parseIdValues(id, idInfoArray);
107
-
108
- const conditions = [];
109
- for (const info of idInfoArray) {
110
- const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
111
- if (!field) {
112
- throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
113
- }
114
- conditions.push(eq(field, parsedIdObj[info.fieldName]));
115
- }
116
-
117
- const result = await this.db
118
- .delete(table)
119
- .where(and(...conditions));
120
-
121
- if ((result.rowCount ?? 0) === 0) {
122
- throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
123
- }
124
- }
125
-
126
- /**
127
- * Delete all rows from a collection
128
- */
129
- async deleteAll(collectionPath: string, _databaseId?: string): Promise<void> {
130
- const collection = getCollectionByPath(collectionPath, this.registry);
131
- const table = getTableForCollection(collection, this.registry);
132
- await this.db.delete(table);
133
- }
134
-
135
- /**
136
- * The field on the *target* row that records the parent, for a create under
137
- * a nested one-to-many path.
138
- *
139
- * A **field**, not the column: the value is stamped into the caller's
140
- * payload, which is keyed by wire names — `authorId`, never the `author_id`
141
- * the relation names its link by. Stamping the column instead put a key on
142
- * the payload that no property answers to, and `strictWrites` rejected the
143
- * request the framework had just written to.
144
- *
145
- * Returns `undefined` when the link is not a column at all (a multi-hop
146
- * `joinPath`), so the caller writes the row without stamping anything.
147
- *
148
- * `relation.localKey` is deliberately not consulted: it names a column on
149
- * the *source* table. Falling back to it here — which is what this used to
150
- * do, and first — stamped the parent's own foreign key onto the child row.
151
- */
152
- private resolveParentForeignKeyColumn(hop: NestedPathHop): string | undefined {
153
- const { relation } = hop;
154
-
155
- switch (relation.kind) {
156
- case "hasOne":
157
- case "hasMany":
158
- return fieldKeyForColumn(hop.targetCollection, relation.foreignKeyOnTarget);
159
-
160
- case "via":
161
- // The link lives in an intermediate table, not in a column on
162
- // the target — nothing to stamp.
163
- return relation.joinPath.length === 1
164
- ? fieldKeyForColumn(
165
- hop.targetCollection,
166
- DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
167
- )
168
- : undefined;
169
-
170
- default:
171
- // `belongsTo` names a column on the *parent*, and `manyToMany`
172
- // is written as a junction row. Neither is a column here, and
173
- // `assertWritableThrough` has already rejected the first.
174
- return undefined;
175
- }
176
- }
177
-
178
- /**
179
- * Save an row (create or update)
180
- *
181
- * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
182
- * UPDATE against the primary key rather than a plain UPDATE. That is one
183
- * statement, so it cannot lose a race the way a read-then-write can, and it
184
- * does not care whether the row already exists — which is what a re-runnable
185
- * import needs.
186
- */
187
- async save<M extends Record<string, unknown>>(
188
- collectionPath: string,
189
- values: Partial<M>,
190
- id?: string | number,
191
- databaseId?: string,
192
- options?: { upsert?: boolean }
193
- ): Promise<Record<string, unknown>> {
194
- // If saving under a nested relation path, resolve the relation it ends in.
195
- let effectiveCollectionPath = collectionPath;
196
- const effectiveValues: Partial<M> = { ...values };
197
- let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation: ResolvedManyToMany; relationKey: string; } | undefined;
198
-
199
- const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
200
-
201
- if (hop) {
202
- assertWritableThrough(hop, collectionPath);
203
- effectiveCollectionPath = hop.targetCollection.slug;
204
-
205
- const parentIdForWrite = () => {
206
- const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
207
- return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
208
- };
209
-
210
- if (isManyToMany(hop.relation)) {
211
- // A junction path addresses set membership, so a write through it
212
- // asserts "this row is in this parent's set" — on create *and* on
213
- // update. The junction row is written after the main write below,
214
- // idempotently, which is what makes `PUT parent/id/child/childId`
215
- // able to attach a row that already exists.
216
- //
217
- // Unlike an owning foreign key, this takes the row from nobody:
218
- // its other parents keep it. That is why linking is safe here
219
- // where reparenting (below) is not.
220
- junctionTableInfo = {
221
- parentCollection: hop.parentCollection,
222
- parentId: parentIdForWrite(),
223
- relation: hop.relation,
224
- relationKey: hop.relationKey
225
- };
226
- } else if (id !== undefined) {
227
- // Updating an existing row *through* an owning parent. The parent
228
- // segment is an assertion about where the row already lives, not
229
- // an instruction to move it there: injecting the FK here silently
230
- // reparented whatever id was named, so `PUT authors/1/posts/43`
231
- // stole post 43 from its real author. Check membership instead,
232
- // and leave the FK to an explicit value in the body.
233
- if (!await this.relationService.isRelated(hop, id)) {
234
- throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
235
- }
236
- } else {
237
- // One-to-many create: stamp the parent's key onto the child's FK.
238
- const targetColumnName = this.resolveParentForeignKeyColumn(hop);
239
-
240
- if (targetColumnName) {
241
- // Not necessarily the id in the path: a link with a
242
- // `sourceKey` is pointed at a column on the parent row, and
243
- // stamping the id would create a child that belongs to
244
- // nobody — a row the read path would never return again.
245
- const parentKeyValue = hasForeignKeyOnTarget(hop.relation)
246
- ? await this.relationService.parentKeyValue(hop.parentCollection, hop.relation, hop.parentId)
247
- : parentIdForWrite();
248
-
249
- if (parentKeyValue === undefined) {
250
- throw ApiError.badRequest(
251
- `Cannot create under "${collectionPath}": the parent row has no value in ` +
252
- `\`sourceKey\`, so the new row has nothing to point at.`
253
- );
254
- }
255
-
256
- const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
257
- if (existingValue !== undefined && existingValue !== null && existingValue !== parentKeyValue) {
258
- logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent key '${parentKeyValue}'.`);
259
- }
260
- (effectiveValues as Record<string, unknown>)[targetColumnName] = parentKeyValue;
261
- }
262
- }
263
- }
264
-
265
- const collection = getCollectionByPath(effectiveCollectionPath, this.registry);
266
- const table = getTableForCollection(collection, this.registry);
267
- const idInfoArray = getPrimaryKeys(collection, this.registry);
268
- const primaryKeyFields = idInfoArray.map(info => info.fieldName);
269
-
270
- // Build an object mapping required for dynamic returning
271
- const returningKeys: Record<string, AnyPgColumn> = {};
272
- idInfoArray.forEach(info => {
273
- const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
274
- if (!field) throw new Error(`Primary key field '${info.fieldName}' not found in table for collection '${effectiveCollectionPath}'`);
275
- returningKeys[info.fieldName] = field;
276
- });
277
-
278
- // Separate relations that require special handling
279
- const relationValues: Record<string, unknown> = {};
280
- const otherValues: Partial<M> = { ...effectiveValues };
281
- const resolvedRelations = resolveCollectionRelations(collection);
282
-
283
- for (const key in resolvedRelations) {
284
- const relation = resolvedRelations[key];
285
- if (relation && relation.cardinality === "many") {
286
- if (Object.prototype.hasOwnProperty.call(otherValues, key)) {
287
- relationValues[key] = otherValues[key as keyof M];
288
- delete otherValues[key as keyof M];
289
- }
290
- }
291
- }
292
-
293
- let savedId: string | number;
294
- try {
295
- // Transform relations to IDs, then sanitize
296
- const serializedResult = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
297
-
298
- // Extract relation updates from the typed result
299
- const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
300
- const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
301
-
302
- const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
303
-
304
- // Everything below builds SQL from the table's own column list, so
305
- // a key that is not a column is silently left out rather than
306
- // refused. Say so here, while the key is still in hand.
307
- assertWritableColumns(entityData as Record<string, unknown>, table, effectiveCollectionPath);
308
-
309
- savedId = await this.db.transaction(async (tx) => {
310
- let currentId: string | number;
311
-
312
- if (id && !options?.upsert) {
313
- // Update existing row
314
- currentId = id; // `id` is already the formatted composite or singular string
315
- const idValues = parseIdValues(id, idInfoArray);
316
-
317
- // Apply joinPath one-to-one relation updates BEFORE the main UPDATE.
318
- // This ensures parentSourceCol reads the pre-update FK value, preventing
319
- // stale joinPath values from corrupting related rows when an
320
- // intermediate FK (e.g., author_id) changes in the same save.
321
- // Example: changing author A→B with stale profile P1 (A's):
322
- // reads old author_id=A → clears P1.author_id → re-sets P1.author_id=A (no-op).
323
- if (joinPathRelationUpdates.length > 0) {
324
- await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
325
- }
326
-
327
- // Only issue an UPDATE if there are scalar columns to set.
328
- // When the payload contains only relation data, entityData is
329
- // empty after relation stripping and Drizzle throws "No values to set".
330
- const scalarKeys = Object.keys(entityData as Record<string, unknown>);
331
- if (scalarKeys.length > 0) {
332
- const updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
333
- const conditions = [];
334
- for (const info of idInfoArray) {
335
- const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
336
- conditions.push(eq(field, idValues[info.fieldName]));
337
- }
338
-
339
- const updateResult = await updateQuery.where(and(...conditions));
340
-
341
- // Throwing rolls the transaction back, so relation writes
342
- // already applied above do not survive a rejected update.
343
- if ((updateResult.rowCount ?? 0) === 0) {
344
- throw await this.explainZeroRowWrite(
345
- tx, table, conditions, effectiveCollectionPath, currentId, "update"
346
- );
347
- }
348
- }
349
- } else {
350
- const dataForInsert = { ...(entityData as Record<string, unknown>) };
351
-
352
- // An explicit id given alongside upsert is the conflict target,
353
- // so fold it into the row before the empty-key strip below.
354
- if (id && options?.upsert) {
355
- Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
356
- }
357
-
358
- // Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
359
- for (const info of idInfoArray) {
360
- if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
361
- delete dataForInsert[info.fieldName];
362
- }
363
- }
364
-
365
- const insertQuery = tx.insert(table).values(dataForInsert);
366
-
367
- // ON CONFLICT needs a real conflict target, and the only one
368
- // guaranteed to exist is the primary key. Without every key
369
- // column present there is nothing to match on, so the row is a
370
- // plain insert and a duplicate should still raise.
371
- const hasFullKey = idInfoArray.length > 0
372
- && idInfoArray.every((info) => dataForInsert[info.fieldName] !== undefined);
373
-
374
- let result;
375
- if (options?.upsert && hasFullKey) {
376
- const target = idInfoArray.map((info) => table[info.fieldName as keyof typeof table] as AnyPgColumn);
377
- const set = { ...dataForInsert };
378
- // Never reassign the key columns to themselves in the UPDATE
379
- // branch; Postgres rejects that against the conflict target.
380
- for (const info of idInfoArray) delete set[info.fieldName];
381
-
382
- // A conflict means the row was already there, so its
383
- // `on_create` stamp is a fact about the past and not
384
- // this write's to redecide. Bulk rows are saved with
385
- // `status: "new"` on purpose (an import's rows carry a
386
- // natural key for rows that may not exist), which
387
- // computes the creation timestamp for every row — and
388
- // the conflict-update then wrote it over the original.
389
- // A nightly re-import reset `createdAt` on everything
390
- // it touched, and every "new this week" query with it.
391
- // The INSERT branch keeps the value: it is right there.
392
- for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
393
- if ((prop as Property).type === "date"
394
- && (prop as { autoValue?: string }).autoValue === "on_create") {
395
- delete set[propName];
396
- }
397
- }
398
-
399
- result = Object.keys(set).length > 0
400
- ? await insertQuery.onConflictDoUpdate({ target, set }).returning(returningKeys)
401
- : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
402
- } else {
403
- result = await insertQuery.returning(returningKeys);
404
- }
405
-
406
- // DO NOTHING returns no row when it skipped, and an upsert whose
407
- // UPDATE branch is filtered by RLS returns none either. Fall back
408
- // to the id we were given rather than reading undefined.
409
- const resultRow = result[0];
410
- if (!resultRow) {
411
- if (id) {
412
- currentId = id;
413
- } else {
414
- throw ApiError.forbidden(
415
- `Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`,
416
- "WRITE_DENIED"
417
- );
418
- }
419
- } else {
420
- currentId = buildCompositeId(resultRow, idInfoArray);
421
- }
422
-
423
- // For inserts, apply joinPath after since the parent row didn't exist before
424
- if (joinPathRelationUpdates.length > 0) {
425
- await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
426
- }
427
- }
428
-
429
- // Handle inverse relation updates
430
- if (inverseRelationUpdates.length > 0) {
431
- await this.relationWrites.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
432
- }
433
-
434
- // Update many-to-many relations
435
- if (Object.keys(relationValues).length > 0) {
436
- await this.relationWrites.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
437
- }
438
-
439
- // Attach the row to the parent's set. Runs for updates as well as
440
- // creates — it used to be gated on `!id`, which is why a row that
441
- // already existed could never be added to a parent through its
442
- // path. The insert is idempotent, so re-asserting a link is a
443
- // no-op rather than a duplicate-key error.
444
- if (junctionTableInfo) {
445
- await this.relationWrites.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
446
- }
447
-
448
- return currentId;
449
- });
450
- } catch (error: unknown) {
451
- throw this.toUserFriendlyError(error, collection.slug);
452
- }
453
-
454
- // Fetch the saved row back through the same walk `GET /:id` serves, so a
455
- // write's answer is the read that follows it. This used to be `fetchOne`
456
- // — the admin view-model walk — whose `__type: "relation"` refs then
457
- // leaked into REST responses, afterSave callbacks, history records and
458
- // app-level realtime payloads, none of which see that shape from any
459
- // read path. The admin does not need them here either: its rows arrive
460
- // over the realtime subscription refetch, which still serves the
461
- // view-model walk.
462
- const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, undefined, databaseId);
463
- if (!finalEntity) throw new Error("Could not fetch row after save.");
464
- return finalEntity;
465
- }
466
-
467
- /**
468
- * Get the RelationService instance for external use
469
- */
470
- getRelationService(): RelationService {
471
- return this.relationService;
472
- }
473
-
474
- /**
475
- * The write half, for external use. Separate from
476
- * {@link getRelationService} because they are separate objects now: reads
477
- * answer questions, writes change rows, and the callers of one are not the
478
- * callers of the other.
479
- */
480
- getRelationWriteService(): RelationWriteService {
481
- return this.relationWrites;
482
- }
483
-
484
- /**
485
- * Get the FetchService instance for external use
486
- */
487
- getFetchService(): FetchService {
488
- return this.fetchService;
489
- }
490
-
491
- /**
492
- * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
493
- */
494
- private toUserFriendlyError(error: unknown, collectionSlug: string): Error {
495
- // Deliberate API errors already carry their own status, code and wording.
496
- // Re-wrapping one flattens it into a generic Error, and the status is lost
497
- // on the way out — a policy rejection would surface as a 500. Matched by
498
- // name as well as instance: a duplicated module copy breaks `instanceof`.
499
- if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
500
- return error as Error;
501
- }
502
-
503
- const pgError = extractPgError(error);
504
-
505
- if (pgError) {
506
- const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
507
- // This is the only layer that holds the SQLSTATE, so it is the only
508
- // one that can say whose fault a failure was. Returning a bare
509
- // `Error` threw that away, and the REST layer compensated by calling
510
- // *every* unclassified error a 400 — so an unreachable database was
511
- // reported to callers as a bad request. Classes 22 (data exception)
512
- // and 23 (integrity constraint violation) are the caller's data;
513
- // everything else — a dropped connection, a missing column, a
514
- // *privilege* problem — is ours, and stays a 500.
515
- if (/^2[23]/.test(code)) {
516
- return code === "23505"
517
- ? ApiError.conflict(message, `PG_${code}`)
518
- : ApiError.badRequest(message, `PG_${code}`);
519
- }
520
- // With one exception inside class 42: a row-level-security policy
521
- // refusing the caller is not a fault at all, it is access control
522
- // working. It fell through to the 500 below, so the client could not
523
- // tell "you may not do this" from "the server is broken" — and a
524
- // 500's message is sanitized on the way out, so the reason was lost
525
- // too. `expected`, because a caller attempting what their policies
526
- // forbid is routine and should not page anyone; that is the same
527
- // treatment `unauthenticated()` gets one status code down.
528
- // `WRITE_DENIED`, the same code `explainZeroRowWrite` returns for an
529
- // UPDATE or DELETE that RLS refused. Those already answered 403; only
530
- // INSERT reached here, because a failed `WITH CHECK` raises 42501 while
531
- // a refused UPDATE simply matches no rows. Two spellings of one denial
532
- // should not be two status codes.
533
- //
534
- // Left at the default log level rather than `expected`: `ApiError`'s
535
- // own documentation puts "a permission the database refused" in the
536
- // stays-at-warn column, and the status code is the defect here. The
537
- // log level is a separate decision that already has an answer.
538
- if (isRowLevelSecurityDenial(error)) {
539
- return ApiError.forbidden(message, "WRITE_DENIED");
540
- }
541
- return new Error(message);
542
- }
543
-
544
- // No PG error found — try to extract a useful message from the
545
- // Drizzle wrapper instead of leaking the raw SQL query + params.
546
- const causeMessage = extractCauseMessage(error);
547
- if (causeMessage) {
548
- return new Error(`Database error in "${collectionSlug}": ${causeMessage}`);
549
- }
550
-
551
- // Last resort: generic message, never leak raw SQL
552
- if (error instanceof Error && error.message.startsWith("Failed query:")) {
553
- return new Error(`Failed to save row in "${collectionSlug}". Check server logs for details.`);
554
- }
555
- return new Error(`Database error in "${collectionSlug}": ${String(error)}`);
556
- }
557
- }