@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
}
|