@rebasepro/server-postgres 0.10.1-canary.7609565 → 0.10.1-canary.7801eed
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/dist/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +1093 -4941
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +55 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { eq, and, sql, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
-
import { CollectionConfig, Properties,
|
|
5
|
-
import { getTableName, resolveCollectionRelations
|
|
4
|
+
import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
@@ -16,6 +16,13 @@ import { RelationService } from "./RelationService";
|
|
|
16
16
|
import { FetchService } from "./FetchService";
|
|
17
17
|
import { DrizzleClient } from "../interfaces";
|
|
18
18
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
19
|
+
import {
|
|
20
|
+
assertWritableThrough,
|
|
21
|
+
isJunctionBackedRelation,
|
|
22
|
+
isNestedPath,
|
|
23
|
+
resolveNestedPath,
|
|
24
|
+
type NestedPathHop
|
|
25
|
+
} from "./nested-path";
|
|
19
26
|
import { ApiError, logger } from "@rebasepro/server";
|
|
20
27
|
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
|
|
21
28
|
|
|
@@ -80,6 +87,33 @@ export class PersistService {
|
|
|
80
87
|
* Delete an row by ID
|
|
81
88
|
*/
|
|
82
89
|
async delete(collectionPath: string, id: string | number, _databaseId?: string): Promise<void> {
|
|
90
|
+
// A nested address deletes *through* a relation, and what that removes
|
|
91
|
+
// depends on who owns the target row.
|
|
92
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
93
|
+
if (hop) {
|
|
94
|
+
assertWritableThrough(hop, collectionPath);
|
|
95
|
+
|
|
96
|
+
if (!await this.relationService.isRelated(hop, id)) {
|
|
97
|
+
throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isJunctionBackedRelation(hop.relation)) {
|
|
101
|
+
// Shared target: drop the link, not the row.
|
|
102
|
+
if (!isManyToMany(hop.relation)) {
|
|
103
|
+
throw ApiError.badRequest(
|
|
104
|
+
`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, ` +
|
|
105
|
+
`so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" ` +
|
|
106
|
+
"directly if that is what you meant.",
|
|
107
|
+
"RELATION_NOT_UNLINKABLE"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
await this.relationService.unlinkRelatedEntity(this.db, hop, id);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Owned child (inverse FK): deleting the row is the right meaning,
|
|
114
|
+
// and membership above has established it is this parent's child.
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
84
118
|
const table = getTableForCollection(collection, this.registry);
|
|
85
119
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
@@ -113,6 +147,40 @@ export class PersistService {
|
|
|
113
147
|
await this.db.delete(table);
|
|
114
148
|
}
|
|
115
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The column on the *target* table that records the parent, for a create
|
|
152
|
+
* under a nested one-to-many path.
|
|
153
|
+
*
|
|
154
|
+
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
155
|
+
* `joinPath`), so the caller writes the row without stamping anything.
|
|
156
|
+
*
|
|
157
|
+
* `relation.localKey` is deliberately not consulted: it names a column on
|
|
158
|
+
* the *source* table. Falling back to it here — which is what this used to
|
|
159
|
+
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
160
|
+
*/
|
|
161
|
+
private resolveParentForeignKeyColumn(hop: NestedPathHop): string | undefined {
|
|
162
|
+
const { relation } = hop;
|
|
163
|
+
|
|
164
|
+
switch (relation.kind) {
|
|
165
|
+
case "hasOne":
|
|
166
|
+
case "hasMany":
|
|
167
|
+
return relation.foreignKeyOnTarget;
|
|
168
|
+
|
|
169
|
+
case "via":
|
|
170
|
+
// The link lives in an intermediate table, not in a column on
|
|
171
|
+
// the target — nothing to stamp.
|
|
172
|
+
return relation.joinPath.length === 1
|
|
173
|
+
? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
default:
|
|
177
|
+
// `belongsTo` names a column on the *parent*, and `manyToMany`
|
|
178
|
+
// is written as a junction row. Neither is a column here, and
|
|
179
|
+
// `assertWritableThrough` has already rejected the first.
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
116
184
|
/**
|
|
117
185
|
* Save an row (create or update)
|
|
118
186
|
*
|
|
@@ -129,93 +197,59 @@ export class PersistService {
|
|
|
129
197
|
databaseId?: string,
|
|
130
198
|
options?: { upsert?: boolean }
|
|
131
199
|
): Promise<Record<string, unknown>> {
|
|
132
|
-
// If saving under a nested relation path, resolve the
|
|
200
|
+
// If saving under a nested relation path, resolve the relation it ends in.
|
|
133
201
|
let effectiveCollectionPath = collectionPath;
|
|
134
202
|
const effectiveValues: Partial<M> = { ...values };
|
|
135
|
-
let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
|
|
184
|
-
|
|
185
|
-
if (relevantJoinStep) {
|
|
186
|
-
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to);
|
|
187
|
-
targetColumnName = targetColumnNames[0];
|
|
188
|
-
} else {
|
|
189
|
-
logger.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
|
|
190
|
-
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
|
|
191
|
-
targetColumnName = targetColumnNames[0];
|
|
192
|
-
}
|
|
193
|
-
} else if (relation.joinPath && relation.joinPath.length > 1) {
|
|
194
|
-
// For multi-hop relations (like many-to-many through a junction table),
|
|
195
|
-
// there is no direct foreign key on the target table pointing to the parent.
|
|
196
|
-
// The relationship is managed via the junction table.
|
|
197
|
-
// We shouldn't inject the parent ID directly into the target row payload.
|
|
198
|
-
break;
|
|
199
|
-
} else {
|
|
200
|
-
throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
|
|
204
|
-
const parentIdInfo = parentIdInfoArray[0];
|
|
205
|
-
const parsedParentIdObj = parseIdValues(currentId, parentIdInfoArray);
|
|
206
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
207
|
-
|
|
208
|
-
const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
|
|
209
|
-
if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
|
|
210
|
-
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
211
|
-
}
|
|
212
|
-
(effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
|
|
213
|
-
break;
|
|
214
|
-
} else {
|
|
215
|
-
const nextEntityId = segments[i + 1];
|
|
216
|
-
currentCollection = relation.target();
|
|
217
|
-
currentId = nextEntityId;
|
|
203
|
+
let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation: ResolvedManyToMany; relationKey: string; } | undefined;
|
|
204
|
+
|
|
205
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
206
|
+
|
|
207
|
+
if (hop) {
|
|
208
|
+
assertWritableThrough(hop, collectionPath);
|
|
209
|
+
effectiveCollectionPath = hop.targetCollection.slug;
|
|
210
|
+
|
|
211
|
+
const parentIdForWrite = () => {
|
|
212
|
+
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
213
|
+
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (isManyToMany(hop.relation)) {
|
|
217
|
+
// A junction path addresses set membership, so a write through it
|
|
218
|
+
// asserts "this row is in this parent's set" — on create *and* on
|
|
219
|
+
// update. The junction row is written after the main write below,
|
|
220
|
+
// idempotently, which is what makes `PUT parent/id/child/childId`
|
|
221
|
+
// able to attach a row that already exists.
|
|
222
|
+
//
|
|
223
|
+
// Unlike an owning foreign key, this takes the row from nobody:
|
|
224
|
+
// its other parents keep it. That is why linking is safe here
|
|
225
|
+
// where reparenting (below) is not.
|
|
226
|
+
junctionTableInfo = {
|
|
227
|
+
parentCollection: hop.parentCollection,
|
|
228
|
+
parentId: parentIdForWrite(),
|
|
229
|
+
relation: hop.relation,
|
|
230
|
+
relationKey: hop.relationKey
|
|
231
|
+
};
|
|
232
|
+
} else if (id !== undefined) {
|
|
233
|
+
// Updating an existing row *through* an owning parent. The parent
|
|
234
|
+
// segment is an assertion about where the row already lives, not
|
|
235
|
+
// an instruction to move it there: injecting the FK here silently
|
|
236
|
+
// reparented whatever id was named, so `PUT authors/1/posts/43`
|
|
237
|
+
// stole post 43 from its real author. Check membership instead,
|
|
238
|
+
// and leave the FK to an explicit value in the body.
|
|
239
|
+
if (!await this.relationService.isRelated(hop, id)) {
|
|
240
|
+
throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
// One-to-many create: stamp the parent's id onto the child's FK.
|
|
244
|
+
const targetColumnName = this.resolveParentForeignKeyColumn(hop);
|
|
245
|
+
|
|
246
|
+
if (targetColumnName) {
|
|
247
|
+
const parsedParentId = parentIdForWrite();
|
|
248
|
+
const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
|
|
249
|
+
if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
|
|
250
|
+
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
218
251
|
}
|
|
252
|
+
(effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
|
|
219
253
|
}
|
|
220
254
|
}
|
|
221
255
|
}
|
|
@@ -372,8 +406,12 @@ export class PersistService {
|
|
|
372
406
|
await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
373
407
|
}
|
|
374
408
|
|
|
375
|
-
//
|
|
376
|
-
|
|
409
|
+
// Attach the row to the parent's set. Runs for updates as well as
|
|
410
|
+
// creates — it used to be gated on `!id`, which is why a row that
|
|
411
|
+
// already existed could never be added to a parent through its
|
|
412
|
+
// path. The insert is idempotent, so re-asserting a link is a
|
|
413
|
+
// no-op rather than a duplicate-key error.
|
|
414
|
+
if (junctionTableInfo) {
|
|
377
415
|
await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
378
416
|
}
|
|
379
417
|
|