@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.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/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- package/dist/websocket-D0TBU3ia.js.map +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
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, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
4
|
+
import { CollectionConfig, Properties, Property, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, fieldKeyForColumn } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
8
|
+
assertWritableColumns,
|
|
8
9
|
getCollectionByPath,
|
|
9
10
|
getTableForCollection,
|
|
10
11
|
getPrimaryKeys,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
} from "./collection-helpers";
|
|
14
15
|
import { sanitizeAndConvertDates, serializeDataToServer } from "../data-transformer";
|
|
15
16
|
import { RelationService } from "./RelationService";
|
|
17
|
+
import { RelationWriteService } from "./RelationWriteService";
|
|
16
18
|
import { FetchService } from "./FetchService";
|
|
17
19
|
import { DrizzleClient } from "../interfaces";
|
|
18
20
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
@@ -25,39 +27,31 @@ import {
|
|
|
25
27
|
} from "./nested-path";
|
|
26
28
|
import { ApiError, logger } from "@rebasepro/server";
|
|
27
29
|
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
|
|
30
|
+
import { explainZeroRowWrite } from "./write-denial";
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Service for handling all row write operations.
|
|
31
34
|
* Handles saving, deleting, and updating rows.
|
|
32
35
|
*/
|
|
33
36
|
export class PersistService {
|
|
37
|
+
/** Reads: whether a row is under a parent, the key a link joins on. */
|
|
34
38
|
private relationService: RelationService;
|
|
39
|
+
/** Writes: junction membership, foreign-key stamping, links. */
|
|
40
|
+
private relationWrites: RelationWriteService;
|
|
35
41
|
private fetchService: FetchService;
|
|
36
42
|
|
|
37
43
|
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
|
|
38
44
|
this.relationService = new RelationService(db, registry);
|
|
45
|
+
this.relationWrites = new RelationWriteService(db, registry);
|
|
39
46
|
this.fetchService = new FetchService(db, registry);
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
|
|
43
50
|
/**
|
|
44
|
-
* Explain a write that matched
|
|
45
|
-
*
|
|
46
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
47
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
48
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
49
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
50
|
-
* the row is untouched.
|
|
51
|
-
*
|
|
52
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
53
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
54
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
55
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
56
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
57
|
-
*
|
|
58
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
51
|
+
* Explain a row write that matched nothing — see {@link explainZeroRowWrite}
|
|
52
|
+
* for why a zero-row write cannot be reported as success.
|
|
59
53
|
*/
|
|
60
|
-
private
|
|
54
|
+
private explainZeroRowWrite(
|
|
61
55
|
handle: DrizzleClient,
|
|
62
56
|
table: PgTable,
|
|
63
57
|
conditions: SQL[],
|
|
@@ -65,20 +59,11 @@ export class PersistService {
|
|
|
65
59
|
id: string | number,
|
|
66
60
|
operation: "update" | "delete"
|
|
67
61
|
): Promise<ApiError> {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (visible.length > 0) {
|
|
75
|
-
return ApiError.forbidden(
|
|
76
|
-
`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`,
|
|
77
|
-
"WRITE_DENIED"
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return ApiError.notFound(
|
|
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.`,
|
|
82
67
|
`No row "${id}" in "${collectionPath}" to ${operation}.`
|
|
83
68
|
);
|
|
84
69
|
}
|
|
@@ -107,7 +92,7 @@ export class PersistService {
|
|
|
107
92
|
"RELATION_NOT_UNLINKABLE"
|
|
108
93
|
);
|
|
109
94
|
}
|
|
110
|
-
await this.
|
|
95
|
+
await this.relationWrites.unlinkRelatedEntity(this.db, hop, id);
|
|
111
96
|
return;
|
|
112
97
|
}
|
|
113
98
|
// Owned child (inverse FK): deleting the row is the right meaning,
|
|
@@ -148,8 +133,14 @@ export class PersistService {
|
|
|
148
133
|
}
|
|
149
134
|
|
|
150
135
|
/**
|
|
151
|
-
* The
|
|
152
|
-
*
|
|
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.
|
|
153
144
|
*
|
|
154
145
|
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
155
146
|
* `joinPath`), so the caller writes the row without stamping anything.
|
|
@@ -164,13 +155,16 @@ export class PersistService {
|
|
|
164
155
|
switch (relation.kind) {
|
|
165
156
|
case "hasOne":
|
|
166
157
|
case "hasMany":
|
|
167
|
-
return relation.foreignKeyOnTarget;
|
|
158
|
+
return fieldKeyForColumn(hop.targetCollection, relation.foreignKeyOnTarget);
|
|
168
159
|
|
|
169
160
|
case "via":
|
|
170
161
|
// The link lives in an intermediate table, not in a column on
|
|
171
162
|
// the target — nothing to stamp.
|
|
172
163
|
return relation.joinPath.length === 1
|
|
173
|
-
?
|
|
164
|
+
? fieldKeyForColumn(
|
|
165
|
+
hop.targetCollection,
|
|
166
|
+
DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
|
|
167
|
+
)
|
|
174
168
|
: undefined;
|
|
175
169
|
|
|
176
170
|
default:
|
|
@@ -307,6 +301,11 @@ export class PersistService {
|
|
|
307
301
|
|
|
308
302
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
309
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
|
+
|
|
310
309
|
savedId = await this.db.transaction(async (tx) => {
|
|
311
310
|
let currentId: string | number;
|
|
312
311
|
|
|
@@ -322,7 +321,7 @@ export class PersistService {
|
|
|
322
321
|
// Example: changing author A→B with stale profile P1 (A's):
|
|
323
322
|
// reads old author_id=A → clears P1.author_id → re-sets P1.author_id=A (no-op).
|
|
324
323
|
if (joinPathRelationUpdates.length > 0) {
|
|
325
|
-
await this.
|
|
324
|
+
await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
326
325
|
}
|
|
327
326
|
|
|
328
327
|
// Only issue an UPDATE if there are scalar columns to set.
|
|
@@ -380,6 +379,23 @@ export class PersistService {
|
|
|
380
379
|
// branch; Postgres rejects that against the conflict target.
|
|
381
380
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
382
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
|
+
|
|
383
399
|
result = Object.keys(set).length > 0
|
|
384
400
|
? await insertQuery.onConflictDoUpdate({ target, set }).returning(returningKeys)
|
|
385
401
|
: await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
@@ -406,18 +422,18 @@ export class PersistService {
|
|
|
406
422
|
|
|
407
423
|
// For inserts, apply joinPath after since the parent row didn't exist before
|
|
408
424
|
if (joinPathRelationUpdates.length > 0) {
|
|
409
|
-
await this.
|
|
425
|
+
await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
410
426
|
}
|
|
411
427
|
}
|
|
412
428
|
|
|
413
429
|
// Handle inverse relation updates
|
|
414
430
|
if (inverseRelationUpdates.length > 0) {
|
|
415
|
-
await this.
|
|
431
|
+
await this.relationWrites.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
416
432
|
}
|
|
417
433
|
|
|
418
434
|
// Update many-to-many relations
|
|
419
435
|
if (Object.keys(relationValues).length > 0) {
|
|
420
|
-
await this.
|
|
436
|
+
await this.relationWrites.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
421
437
|
}
|
|
422
438
|
|
|
423
439
|
// Attach the row to the parent's set. Runs for updates as well as
|
|
@@ -426,7 +442,7 @@ export class PersistService {
|
|
|
426
442
|
// path. The insert is idempotent, so re-asserting a link is a
|
|
427
443
|
// no-op rather than a duplicate-key error.
|
|
428
444
|
if (junctionTableInfo) {
|
|
429
|
-
await this.
|
|
445
|
+
await this.relationWrites.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
430
446
|
}
|
|
431
447
|
|
|
432
448
|
return currentId;
|
|
@@ -455,6 +471,16 @@ export class PersistService {
|
|
|
455
471
|
return this.relationService;
|
|
456
472
|
}
|
|
457
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
|
+
|
|
458
484
|
/**
|
|
459
485
|
* Get the FetchService instance for external use
|
|
460
486
|
*/
|