@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,4 +1,5 @@
|
|
|
1
1
|
import { RelationService } from "./RelationService";
|
|
2
|
+
import { RelationWriteService } from "./RelationWriteService";
|
|
2
3
|
import { FetchService } from "./FetchService";
|
|
3
4
|
import { DrizzleClient } from "../interfaces";
|
|
4
5
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
@@ -9,25 +10,15 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
|
|
|
9
10
|
export declare class PersistService {
|
|
10
11
|
private db;
|
|
11
12
|
private registry;
|
|
13
|
+
/** Reads: whether a row is under a parent, the key a link joins on. */
|
|
12
14
|
private relationService;
|
|
15
|
+
/** Writes: junction membership, foreign-key stamping, links. */
|
|
16
|
+
private relationWrites;
|
|
13
17
|
private fetchService;
|
|
14
18
|
constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
|
|
15
19
|
/**
|
|
16
|
-
* Explain a write that matched
|
|
17
|
-
*
|
|
18
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
19
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
20
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
21
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
22
|
-
* the row is untouched.
|
|
23
|
-
*
|
|
24
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
25
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
26
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
27
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
28
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
29
|
-
*
|
|
30
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
20
|
+
* Explain a row write that matched nothing — see {@link explainZeroRowWrite}
|
|
21
|
+
* for why a zero-row write cannot be reported as success.
|
|
31
22
|
*/
|
|
32
23
|
private explainZeroRowWrite;
|
|
33
24
|
/**
|
|
@@ -39,8 +30,14 @@ export declare class PersistService {
|
|
|
39
30
|
*/
|
|
40
31
|
deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
|
|
41
32
|
/**
|
|
42
|
-
* The
|
|
43
|
-
*
|
|
33
|
+
* The field on the *target* row that records the parent, for a create under
|
|
34
|
+
* a nested one-to-many path.
|
|
35
|
+
*
|
|
36
|
+
* A **field**, not the column: the value is stamped into the caller's
|
|
37
|
+
* payload, which is keyed by wire names — `authorId`, never the `author_id`
|
|
38
|
+
* the relation names its link by. Stamping the column instead put a key on
|
|
39
|
+
* the payload that no property answers to, and `strictWrites` rejected the
|
|
40
|
+
* request the framework had just written to.
|
|
44
41
|
*
|
|
45
42
|
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
46
43
|
* `joinPath`), so the caller writes the row without stamping anything.
|
|
@@ -66,6 +63,13 @@ export declare class PersistService {
|
|
|
66
63
|
* Get the RelationService instance for external use
|
|
67
64
|
*/
|
|
68
65
|
getRelationService(): RelationService;
|
|
66
|
+
/**
|
|
67
|
+
* The write half, for external use. Separate from
|
|
68
|
+
* {@link getRelationService} because they are separate objects now: reads
|
|
69
|
+
* answer questions, writes change rows, and the callers of one are not the
|
|
70
|
+
* callers of the other.
|
|
71
|
+
*/
|
|
72
|
+
getRelationWriteService(): RelationWriteService;
|
|
69
73
|
/**
|
|
70
74
|
* Get the FetchService instance for external use
|
|
71
75
|
*/
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { DrizzleClient } from "../interfaces";
|
|
2
|
-
import { CollectionConfig, FilterValues, ResolvedRelation,
|
|
3
|
-
import { type ResolvedVia } from "@rebasepro/types";
|
|
2
|
+
import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
4
3
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
5
4
|
import type { NestedPathHop } from "./nested-path";
|
|
6
5
|
/**
|
|
@@ -78,7 +77,14 @@ export declare class RelationService {
|
|
|
78
77
|
* nested path and would otherwise write the id and lose the row.
|
|
79
78
|
*/
|
|
80
79
|
parentKeyValue(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentId: string | number, db?: DrizzleClient): Promise<string | number | undefined>;
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Shared with {@link RelationWriteService}: a write needs the same source
|
|
82
|
+
* key a read does, and resolving it twice is how the two would disagree.
|
|
83
|
+
*/
|
|
84
|
+
resolveSourceKeys(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentIds: (string | number)[], db?: DrizzleClient): Promise<{
|
|
85
|
+
keyByParentId: Map<string, string | number>;
|
|
86
|
+
parentIdByKey: Map<string, string | number>;
|
|
87
|
+
}>;
|
|
82
88
|
/**
|
|
83
89
|
* Fetch rows related to a parent row through a specific relation
|
|
84
90
|
*/
|
|
@@ -129,16 +135,6 @@ export declare class RelationService {
|
|
|
129
135
|
* belonged to. Reads, updates and deletes now all gate on this.
|
|
130
136
|
*/
|
|
131
137
|
isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean>;
|
|
132
|
-
/**
|
|
133
|
-
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
134
|
-
* row itself alone.
|
|
135
|
-
*
|
|
136
|
-
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
137
|
-
* target is shared, so deleting the row would remove the tag from every other
|
|
138
|
-
* post that uses it. It used to do exactly that — resolve the path to the
|
|
139
|
-
* `tags` table and delete by primary key.
|
|
140
|
-
*/
|
|
141
|
-
unlinkRelatedEntity(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number): Promise<void>;
|
|
142
138
|
/**
|
|
143
139
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
144
140
|
*/
|
|
@@ -149,48 +145,4 @@ export declare class RelationService {
|
|
|
149
145
|
* Uses a single SQL query with IN clause to avoid N+1.
|
|
150
146
|
*/
|
|
151
147
|
batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>>;
|
|
152
|
-
/**
|
|
153
|
-
* Update many-to-many and junction relations
|
|
154
|
-
*/
|
|
155
|
-
updateRelationsUsingJoins<M extends Record<string, unknown>>(tx: DrizzleClient, collection: CollectionConfig, id: string | number, relationValues: Partial<M>): Promise<void>;
|
|
156
|
-
/**
|
|
157
|
-
* Update inverse relations (where FK is on the target table)
|
|
158
|
-
*/
|
|
159
|
-
updateInverseRelations(tx: DrizzleClient, sourceCollection: CollectionConfig, sourceEntityId: string | number, inverseRelationUpdates: Array<{
|
|
160
|
-
relationKey: string;
|
|
161
|
-
relation: ResolvedRelation;
|
|
162
|
-
newValue: unknown;
|
|
163
|
-
}>): Promise<void>;
|
|
164
|
-
/**
|
|
165
|
-
* Handle inverse relations with joinPath
|
|
166
|
-
*/
|
|
167
|
-
private updateInverseJoinPathRelation;
|
|
168
|
-
/**
|
|
169
|
-
* Handle many-to-many inverse relation updates using junction tables
|
|
170
|
-
*/
|
|
171
|
-
private updateManyToManyInverseRelation;
|
|
172
|
-
/**
|
|
173
|
-
* Update one-to-one relations that use joinPath
|
|
174
|
-
*/
|
|
175
|
-
updateJoinPathOneToOneRelations(tx: DrizzleClient, parentCollection: CollectionConfig, parentId: string | number, updates: Array<{
|
|
176
|
-
relationKey: string;
|
|
177
|
-
relation: ResolvedVia;
|
|
178
|
-
newTargetId: string | number | null;
|
|
179
|
-
}>): Promise<void>;
|
|
180
|
-
/**
|
|
181
|
-
* Resolve joinPath write mapping for one-to-one relations
|
|
182
|
-
*/
|
|
183
|
-
resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation: ResolvedVia): {
|
|
184
|
-
targetFKColName: string;
|
|
185
|
-
parentSourceColName: string;
|
|
186
|
-
};
|
|
187
|
-
/**
|
|
188
|
-
* Handle junction table creation for many-to-many path-based saves
|
|
189
|
-
*/
|
|
190
|
-
handleJunctionTableCreation(tx: DrizzleClient, newEntityId: string | number, junctionTableInfo: {
|
|
191
|
-
parentCollection: CollectionConfig;
|
|
192
|
-
parentId: string | number;
|
|
193
|
-
relation: ResolvedManyToMany;
|
|
194
|
-
relationKey: string;
|
|
195
|
-
}): Promise<void>;
|
|
196
148
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { CollectionConfig, ResolvedManyToMany, ResolvedRelation, ResolvedVia } from "@rebasepro/types";
|
|
2
|
+
import { DrizzleClient } from "../interfaces";
|
|
3
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
|
+
import type { NestedPathHop } from "./nested-path";
|
|
5
|
+
/**
|
|
6
|
+
* Writing relations: junction membership, foreign-key stamping, and the links a
|
|
7
|
+
* nested path creates or removes.
|
|
8
|
+
*
|
|
9
|
+
* Split from {@link RelationService}, which now only reads. The two had grown
|
|
10
|
+
* into one 1700-line class doing two unrelated jobs, and sharing one habit —
|
|
11
|
+
* warning about a relation it could not resolve and carrying on, which surfaces
|
|
12
|
+
* as an empty list on the read side and as a successful save on the write side.
|
|
13
|
+
* Separating them is what made that visible as one class of defect rather than
|
|
14
|
+
* eighteen scattered log lines.
|
|
15
|
+
*
|
|
16
|
+
* The reads it still needs — the source key a link joins on — it asks
|
|
17
|
+
* {@link RelationService} for rather than reimplementing.
|
|
18
|
+
*/
|
|
19
|
+
export declare class RelationWriteService {
|
|
20
|
+
private db;
|
|
21
|
+
private registry;
|
|
22
|
+
private reads;
|
|
23
|
+
constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
|
|
24
|
+
/**
|
|
25
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
26
|
+
* row itself alone.
|
|
27
|
+
*
|
|
28
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
29
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
30
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
31
|
+
* `tags` table and delete by primary key.
|
|
32
|
+
*/
|
|
33
|
+
unlinkRelatedEntity(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number): Promise<void>;
|
|
34
|
+
/** A collection's id, parsed to the type its primary key column holds. */
|
|
35
|
+
private parsedId;
|
|
36
|
+
/** The same, for the membership list a to-many write names. */
|
|
37
|
+
private parsedIds;
|
|
38
|
+
/**
|
|
39
|
+
* Update many-to-many and junction relations
|
|
40
|
+
*/
|
|
41
|
+
updateRelationsUsingJoins<M extends Record<string, unknown>>(tx: DrizzleClient, collection: CollectionConfig, id: string | number, relationValues: Partial<M>): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Update inverse relations (where FK is on the target table)
|
|
44
|
+
*/
|
|
45
|
+
updateInverseRelations(tx: DrizzleClient, sourceCollection: CollectionConfig, sourceEntityId: string | number, inverseRelationUpdates: Array<{
|
|
46
|
+
relationKey: string;
|
|
47
|
+
relation: ResolvedRelation;
|
|
48
|
+
newValue: unknown;
|
|
49
|
+
}>): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Handle inverse relations with joinPath
|
|
52
|
+
*/
|
|
53
|
+
private updateInverseJoinPathRelation;
|
|
54
|
+
/**
|
|
55
|
+
* Handle many-to-many inverse relation updates using junction tables
|
|
56
|
+
*/
|
|
57
|
+
private updateManyToManyInverseRelation;
|
|
58
|
+
/**
|
|
59
|
+
* Update one-to-one relations that use joinPath
|
|
60
|
+
*/
|
|
61
|
+
updateJoinPathOneToOneRelations(tx: DrizzleClient, parentCollection: CollectionConfig, parentId: string | number, updates: Array<{
|
|
62
|
+
relationKey: string;
|
|
63
|
+
relation: ResolvedVia;
|
|
64
|
+
newTargetId: string | number | null;
|
|
65
|
+
}>): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Resolve joinPath write mapping for one-to-one relations
|
|
68
|
+
*/
|
|
69
|
+
resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation: ResolvedVia): {
|
|
70
|
+
targetFKColName: string;
|
|
71
|
+
parentSourceColName: string;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Handle junction table creation for many-to-many path-based saves
|
|
75
|
+
*/
|
|
76
|
+
handleJunctionTableCreation(tx: DrizzleClient, newEntityId: string | number, junctionTableInfo: {
|
|
77
|
+
parentCollection: CollectionConfig;
|
|
78
|
+
parentId: string | number;
|
|
79
|
+
relation: ResolvedManyToMany;
|
|
80
|
+
relationKey: string;
|
|
81
|
+
}): Promise<void>;
|
|
82
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
2
|
import { CollectionConfig, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
3
3
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
|
+
import { ApiError } from "@rebasepro/server";
|
|
4
5
|
export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
5
6
|
export type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
6
7
|
import type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
@@ -34,6 +35,47 @@ export declare function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta;
|
|
|
34
35
|
*/
|
|
35
36
|
export declare function idCanAddressTable(id: string | number, table: PgTable, idInfoArray: PrimaryKeyInfo[]): boolean;
|
|
36
37
|
export declare function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig;
|
|
38
|
+
/**
|
|
39
|
+
* Reject a write naming something that is not a column of the table.
|
|
40
|
+
*
|
|
41
|
+
* Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
|
|
42
|
+
* from `Object.keys(tableColumns)`, so a key the table does not carry is not
|
|
43
|
+
* rejected by anything — it is *left out of the statement*. The insert answers
|
|
44
|
+
* 201 having stored nothing under that name; the update, if the key was the
|
|
45
|
+
* only one, builds `update "posts" set where …` and Postgres raises a syntax
|
|
46
|
+
* error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
|
|
47
|
+
* 500 for what is a caller's typo.
|
|
48
|
+
*
|
|
49
|
+
* That makes this the last honest place to check, and the only one every write
|
|
50
|
+
* passes through. `assertKnownWriteFields` in the REST layer checks the same
|
|
51
|
+
* thing against the *config* and is skipped on four paths — `strictWrites:
|
|
52
|
+
* false`, a collection declaring no properties, an auth adapter that owns the
|
|
53
|
+
* body's shape, and a nested route whose target cannot be walked — and it never
|
|
54
|
+
* sees an in-process `rebase.data` write at all.
|
|
55
|
+
*
|
|
56
|
+
* It also gives `strictWrites: false` a truthful implementation. The flag is
|
|
57
|
+
* documented for "a column that really does exist which the config never
|
|
58
|
+
* declared", and skipping the config check alone could not deliver that: the
|
|
59
|
+
* value was dropped a layer later regardless. Skipping the config check and
|
|
60
|
+
* keeping this one does exactly what the flag says — the column must exist,
|
|
61
|
+
* the property need not.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertWritableColumns(values: Record<string, unknown>, table: PgTable, collectionPath: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* A relation whose names do not resolve against the registered schema.
|
|
66
|
+
*
|
|
67
|
+
* Every one of these used to be a `logger.warn` followed by `continue`, so a
|
|
68
|
+
* save reported success for a relation it had not written and a read answered
|
|
69
|
+
* `[]` for one it could not resolve. `assertRelationsResolve` (validate-relations)
|
|
70
|
+
* fails boot on the same defects, which is where they belong — a server that
|
|
71
|
+
* refuses to start is recoverable in a minute. This is the second line, for the
|
|
72
|
+
* paths that assemble a registry by hand, and it exists so that "cannot resolve"
|
|
73
|
+
* is never again reported as "done".
|
|
74
|
+
*
|
|
75
|
+
* @param label `<collection>.<relation>`
|
|
76
|
+
* @param detail what does not resolve, in terms of the schema
|
|
77
|
+
*/
|
|
78
|
+
export declare function relationMisconfigured(label: string, detail: string): ApiError;
|
|
37
79
|
export declare function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any>;
|
|
38
80
|
/**
|
|
39
81
|
* The key columns a collection's rows are addressed by.
|
|
@@ -52,6 +52,8 @@ export declare class DataService implements DataRepository {
|
|
|
52
52
|
*/
|
|
53
53
|
searchRows<M extends Record<string, unknown>>(collectionPath: string, searchString: string, options?: {
|
|
54
54
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
55
|
+
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
56
|
+
logical?: LogicalCondition;
|
|
55
57
|
orderBy?: string;
|
|
56
58
|
order?: "desc" | "asc";
|
|
57
59
|
limit?: number;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
2
|
+
import type { ResolvedVia } from "@rebasepro/types";
|
|
3
|
+
import { DrizzleClient } from "../interfaces";
|
|
4
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
5
|
+
/**
|
|
6
|
+
* Writing a many-to-many means writing rows in a junction table, and doing that
|
|
7
|
+
* needs three things: the table, the column naming the row being written from,
|
|
8
|
+
* and the column naming the far side.
|
|
9
|
+
*
|
|
10
|
+
* Those three used to be re-derived at each of the four call sites — twice for
|
|
11
|
+
* `through`, twice for `joinPath`, in the owning and the inverse direction —
|
|
12
|
+
* and the two `joinPath` derivations did not agree. The inverse one keyed off
|
|
13
|
+
* `step.table`; the owning one asked `getTableNamesFromColumns` which table a
|
|
14
|
+
* step's columns belonged to, and that answers `""` for an unqualified column
|
|
15
|
+
* name. So for `{ table: "posts_tags", on: { from: "id", to: "tag_id" } }` —
|
|
16
|
+
* the form the docs and every fixture use — no branch matched, both columns
|
|
17
|
+
* stayed null, and the write was skipped with a warning nobody reads. Writing
|
|
18
|
+
* a to-many `via` relation did nothing at all.
|
|
19
|
+
*
|
|
20
|
+
* Hence one binder, keyed on `step.table` (always present) and accepting the
|
|
21
|
+
* qualified `table.column` form where it is used.
|
|
22
|
+
*/
|
|
23
|
+
export interface JunctionBinding {
|
|
24
|
+
table: PgTable;
|
|
25
|
+
/** The junction column holding the id of the row being written from. */
|
|
26
|
+
parentColumn: AnyPgColumn;
|
|
27
|
+
/** The junction column holding the id of the row on the far side. */
|
|
28
|
+
targetColumn: AnyPgColumn;
|
|
29
|
+
/** `<collection>.<relation>`, for error messages. */
|
|
30
|
+
label: string;
|
|
31
|
+
}
|
|
32
|
+
/** The junction a `manyToMany` names outright. */
|
|
33
|
+
export declare function bindThroughJunction(registry: PostgresCollectionRegistry, through: {
|
|
34
|
+
table: string;
|
|
35
|
+
sourceColumn: string;
|
|
36
|
+
targetColumn: string;
|
|
37
|
+
}, label: string): JunctionBinding;
|
|
38
|
+
/**
|
|
39
|
+
* The junction a `via` relation reaches through, from either end.
|
|
40
|
+
*
|
|
41
|
+
* A step is `{ table: T, on: { from, to } }` where `from` names a column on
|
|
42
|
+
* whatever the walk was standing on and `to` names one on `T`. That positional
|
|
43
|
+
* meaning is what makes the unqualified form work at all, so the walk carries
|
|
44
|
+
* the previous table rather than asking the column names where they live.
|
|
45
|
+
*/
|
|
46
|
+
export declare function bindJoinPathJunction(registry: PostgresCollectionRegistry, joinPath: ResolvedVia["joinPath"], parentTableName: string, targetTableName: string, label: string): JunctionBinding;
|
|
47
|
+
/**
|
|
48
|
+
* Remove one link, leaving the row on the far side alone.
|
|
49
|
+
*
|
|
50
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
51
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
52
|
+
* post that uses it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function removeJunctionLink(tx: DrizzleClient, binding: JunctionBinding, parentId: unknown, targetId: unknown, subject: {
|
|
55
|
+
parent: string;
|
|
56
|
+
relation: string;
|
|
57
|
+
}): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Make the junction say that `parentId` is linked to exactly `targetIds`, by
|
|
60
|
+
* diffing against what is linked now rather than replacing the set.
|
|
61
|
+
*
|
|
62
|
+
* A save of the parent used to delete every junction row for it and re-insert
|
|
63
|
+
* the ids the browser sent — a list the browser assembled from a read it did
|
|
64
|
+
* earlier. Three things followed, all data loss rather than display:
|
|
65
|
+
*
|
|
66
|
+
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
|
|
67
|
+
* saves any field from a form that predates it, and X is gone with nothing
|
|
68
|
+
* reported to either of them.
|
|
69
|
+
* - **A partially-read set is a partially-deleted set.** The read that fills
|
|
70
|
+
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
71
|
+
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
72
|
+
* the links they were never shown. The select that drives the diff runs in
|
|
73
|
+
* this same transaction under the same policies, so a link the caller cannot
|
|
74
|
+
* read is in neither list and survives the save.
|
|
75
|
+
* - **Junction payload columns.** A junction carrying its own columns
|
|
76
|
+
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
77
|
+
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
78
|
+
*
|
|
79
|
+
* The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
|
|
80
|
+
* concurrently is a no-op rather than a unique violation.
|
|
81
|
+
*/
|
|
82
|
+
export declare function applyJunctionMembership(tx: DrizzleClient, binding: JunctionBinding, parentId: unknown, targetIds: unknown[]): Promise<void>;
|
|
@@ -14,6 +14,34 @@ export interface SubscriptionAuthContext {
|
|
|
14
14
|
uid: string;
|
|
15
15
|
roles: string[];
|
|
16
16
|
}
|
|
17
|
+
/** What a channel frame is asking to do. */
|
|
18
|
+
export type ChannelAction = "join" | "broadcast" | "presence" | "history";
|
|
19
|
+
/** Everything an authorizer is told about the frame it is asked to allow. */
|
|
20
|
+
export interface ChannelAuthorizationRequest {
|
|
21
|
+
/** The channel the frame names, exactly as the client wrote it. */
|
|
22
|
+
channel: string;
|
|
23
|
+
action: ChannelAction;
|
|
24
|
+
/** The socket, not the principal — one user may hold several. */
|
|
25
|
+
clientId: string;
|
|
26
|
+
/** The socket's authenticated principal, or the anonymous one. */
|
|
27
|
+
user?: SubscriptionAuthContext;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The extension point for channel access rules.
|
|
31
|
+
*
|
|
32
|
+
* **This is deliberately not a product API yet.** The rule *language* — a
|
|
33
|
+
* config key, a per-pattern DSL, how it composes with `securityRules` — is an
|
|
34
|
+
* open design question (see `docs/channel-authorization.md`), and
|
|
35
|
+
* inventing one here would be inventing the answer. What exists is the single
|
|
36
|
+
* place every channel frame passes through, so that whatever shape the rules
|
|
37
|
+
* eventually take has exactly one seam to plug into and no arm of the switch
|
|
38
|
+
* can be forgotten.
|
|
39
|
+
*
|
|
40
|
+
* Returning `false` — or throwing — refuses the frame. It is consulted *after*
|
|
41
|
+
* the membership floor below, so an authorizer can only ever narrow access,
|
|
42
|
+
* never widen it.
|
|
43
|
+
*/
|
|
44
|
+
export type ChannelAuthorizer = (request: ChannelAuthorizationRequest) => boolean | Promise<boolean>;
|
|
17
45
|
/**
|
|
18
46
|
* The narrowing a collection subscription was created with, kept so that every
|
|
19
47
|
* refetch answers the same query the initial fetch did.
|
|
@@ -46,6 +74,12 @@ type StoredCollectionRequest = {
|
|
|
46
74
|
export declare class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
47
75
|
private db;
|
|
48
76
|
private registry;
|
|
77
|
+
/**
|
|
78
|
+
* Declares to the multi-engine router that channel frames can be handled
|
|
79
|
+
* here. Read by `createRoutedRealtimeService`, which otherwise would have to
|
|
80
|
+
* guess — and guessed "the default provider", whichever engine that is.
|
|
81
|
+
*/
|
|
82
|
+
readonly supportsChannels = true;
|
|
49
83
|
private clients;
|
|
50
84
|
private channels;
|
|
51
85
|
private presence;
|
|
@@ -92,6 +126,23 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
92
126
|
* so a hot channel logs the problem once rather than once per message.
|
|
93
127
|
*/
|
|
94
128
|
private oversizedBroadcastWarned;
|
|
129
|
+
/**
|
|
130
|
+
* Optional narrowing on top of the membership floor — see
|
|
131
|
+
* {@link ChannelAuthorizer}. Unset by default, which leaves membership as
|
|
132
|
+
* the whole of the rule.
|
|
133
|
+
*/
|
|
134
|
+
private channelAuthorizer?;
|
|
135
|
+
/**
|
|
136
|
+
* Whether a notification from another instance has ever arrived.
|
|
137
|
+
*
|
|
138
|
+
* The entity LISTEN handler sees a foreign `sid` on every cross-instance
|
|
139
|
+
* change, which is proof that this deployment runs more than one pod — the
|
|
140
|
+
* one fact needed to tell "the memory bus is fine here" from "broadcast and
|
|
141
|
+
* presence silently reach a fraction of your users".
|
|
142
|
+
*/
|
|
143
|
+
private foreignInstanceSeen;
|
|
144
|
+
/** So the multi-pod memory-bus warning is emitted once, not once per join. */
|
|
145
|
+
private memoryBusWarned;
|
|
95
146
|
private presenceInterval?;
|
|
96
147
|
private static readonly PRESENCE_TIMEOUT_MS;
|
|
97
148
|
/** How often stale roster rows from other instances are reaped. */
|
|
@@ -196,7 +247,39 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
196
247
|
*/
|
|
197
248
|
notifyUpdate(path: string, id: string, row: Record<string, unknown> | null, databaseId?: string, broadcast?: boolean, origin?: "app" | "cdc"): Promise<void>;
|
|
198
249
|
/**
|
|
199
|
-
* Notify subscriptions for a specific path
|
|
250
|
+
* Notify subscriptions for a specific path.
|
|
251
|
+
*
|
|
252
|
+
* **A subscriber only ever receives rows re-read under its own scope.**
|
|
253
|
+
* `row` is used to decide *that* something changed, never to say *what* —
|
|
254
|
+
* every delivery below goes through a refetch that binds the subscription's
|
|
255
|
+
* own auth context.
|
|
256
|
+
*
|
|
257
|
+
* It used to be conditional. The CDC path already did the right thing: it
|
|
258
|
+
* discards the captured tuple and emits `{_rebase_invalidated: true}`, and
|
|
259
|
+
* that marker selected the refetch branch. But the marker is produced in
|
|
260
|
+
* exactly two places, and the *other* side of each branch here shipped the
|
|
261
|
+
* row it was handed straight to the socket. Two of the three entry paths
|
|
262
|
+
* took that side — every API mutation (`PostgresBackendDriver.save` passes
|
|
263
|
+
* the row it just wrote, read under the **writer's** scope) and the legacy
|
|
264
|
+
* cross-instance LISTEN handler (which re-reads on the owner connection,
|
|
265
|
+
* bypassing RLS altogether). Path matching was the only filter applied: the
|
|
266
|
+
* subscription's own `filter`/`logical` was never evaluated, and any
|
|
267
|
+
* `afterRead` redaction was the writer's rather than the reader's.
|
|
268
|
+
*
|
|
269
|
+
* A single-row subscription was the sharpest case. `subscribe_one` on a row
|
|
270
|
+
* RLS denies is accepted and answered `null`; the next update then pushed
|
|
271
|
+
* the full row with no later correction. The collection variant was merely
|
|
272
|
+
* papered over ~300 ms later by the debounced refetch — after the bytes had
|
|
273
|
+
* already reached the browser.
|
|
274
|
+
*
|
|
275
|
+
* The same defect was found and fixed on the Mongo driver in `065e2b615`
|
|
276
|
+
* (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
|
|
277
|
+
* the Postgres half, stated as one rule rather than three patched branches.
|
|
278
|
+
*
|
|
279
|
+
* The cost is the instant row-level patch that used to precede the refetch:
|
|
280
|
+
* cross-tab feedback now waits for the debounce. That is the price of not
|
|
281
|
+
* being able to know, without asking the database as this subscriber,
|
|
282
|
+
* whether this subscriber may see the row at all.
|
|
200
283
|
*/
|
|
201
284
|
private notifyPathUpdate;
|
|
202
285
|
/**
|
|
@@ -237,7 +320,6 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
237
320
|
* columns and no address. The SDK holds no collection config to derive one
|
|
238
321
|
* from, so this is the only place the mapping can come from.
|
|
239
322
|
*/
|
|
240
|
-
private sendCollectionPatch;
|
|
241
323
|
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
242
324
|
private primaryKeysForPath;
|
|
243
325
|
private sendError;
|
|
@@ -247,8 +329,61 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
247
329
|
* Returns ["posts", "posts/70"] for the example above
|
|
248
330
|
*/
|
|
249
331
|
private getParentPaths;
|
|
332
|
+
/**
|
|
333
|
+
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
334
|
+
*
|
|
335
|
+
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
336
|
+
* be built on, kept deliberately separate from the membership floor so the
|
|
337
|
+
* floor holds whether or not anyone uses it.
|
|
338
|
+
*/
|
|
339
|
+
setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void;
|
|
340
|
+
/** Which action each channel frame is asking to perform. */
|
|
341
|
+
private static readonly CHANNEL_ACTIONS;
|
|
342
|
+
/**
|
|
343
|
+
* The one door every channel frame comes through.
|
|
344
|
+
*
|
|
345
|
+
* Returns synchronously — and so dispatches synchronously — unless an
|
|
346
|
+
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
347
|
+
* `presence_state` and `channel_history` back to back on connect, and the
|
|
348
|
+
* socket's message handler processes each frame up to its first `await`,
|
|
349
|
+
* so a gate that always yielded would let the reads overtake the join that
|
|
350
|
+
* is about to authorize them.
|
|
351
|
+
*/
|
|
352
|
+
private handleChannelMessage;
|
|
353
|
+
/** Perform an already-authorized channel frame. */
|
|
354
|
+
private dispatchChannelMessage;
|
|
355
|
+
/**
|
|
356
|
+
* Decide whether a client may perform an action on a channel.
|
|
357
|
+
*
|
|
358
|
+
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
359
|
+
* its retained history and broadcasting into it all require that this client
|
|
360
|
+
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
361
|
+
* the channel — but it is not the bar that was there before, which was none
|
|
362
|
+
* at all: `channel_history` and `presence_state` answered any socket about
|
|
363
|
+
* any channel, and a broadcast fanned out to members the sender had never
|
|
364
|
+
* joined. Two internal tables (`rebase.channel_presence`,
|
|
365
|
+
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
366
|
+
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
367
|
+
*
|
|
368
|
+
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
369
|
+
* this method is where it is consulted, and the only place.
|
|
370
|
+
*/
|
|
371
|
+
private authorizeChannelAction;
|
|
372
|
+
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
373
|
+
private denyChannelAction;
|
|
250
374
|
/** Join a broadcast channel */
|
|
251
375
|
joinChannel(clientId: string, channel: string): void;
|
|
376
|
+
/**
|
|
377
|
+
* Say something the first time channels are used on a deployment that is
|
|
378
|
+
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
379
|
+
*
|
|
380
|
+
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
381
|
+
* the case where the operator already knew a bus mattered. The common
|
|
382
|
+
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
383
|
+
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
384
|
+
* fraction of the room. The evidence is already in the process, so use it.
|
|
385
|
+
*/
|
|
386
|
+
private warnIfMemoryBusOnMultiplePods;
|
|
252
387
|
/** Leave a broadcast channel */
|
|
253
388
|
leaveChannel(clientId: string, channel: string): void;
|
|
254
389
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SQL } from "drizzle-orm";
|
|
2
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { ApiError } from "@rebasepro/server";
|
|
4
|
+
import { DrizzleClient } from "../interfaces";
|
|
5
|
+
/**
|
|
6
|
+
* Explain a write that matched no rows.
|
|
7
|
+
*
|
|
8
|
+
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
9
|
+
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
10
|
+
* like a successful one that happened to match nothing. Left unchecked, a
|
|
11
|
+
* caller cannot tell "denied" from "done" — the write returns 200/204 and the
|
|
12
|
+
* row is untouched. An agent handed a key with `orders:delete` and no delete
|
|
13
|
+
* policy is the case that makes it concrete: it deletes nothing, forever, and
|
|
14
|
+
* is told it worked every time.
|
|
15
|
+
*
|
|
16
|
+
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
17
|
+
* cases. A visible row means the policy rejected the write (403); an invisible
|
|
18
|
+
* one means there is nothing there to write for this caller (404, matching what
|
|
19
|
+
* a GET would say). The re-read is bound by the caller's own policies, so it
|
|
20
|
+
* discloses nothing a plain read wouldn't.
|
|
21
|
+
*
|
|
22
|
+
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
23
|
+
*
|
|
24
|
+
* It lives here, rather than beside its first caller, because every zero-row
|
|
25
|
+
* write has to answer the same question and answer it identically: the rule
|
|
26
|
+
* that a readable-but-unwritable target is a 403 is the contract, and a second
|
|
27
|
+
* copy of it is a second chance to get it wrong.
|
|
28
|
+
*
|
|
29
|
+
* @param handle The RLS-scoped connection the write ran on — not a fresh
|
|
30
|
+
* one, or the re-read would answer for a different caller.
|
|
31
|
+
* @param table The table the write targeted (the junction, for a link).
|
|
32
|
+
* @param conditions The write's own WHERE terms, reused verbatim.
|
|
33
|
+
* @param denied Message for the 403: the target is there and was refused.
|
|
34
|
+
* @param missing Message for the 404: there is nothing there for this caller.
|
|
35
|
+
*/
|
|
36
|
+
export declare function explainZeroRowWrite(handle: DrizzleClient, table: PgTable, conditions: SQL[], denied: string, missing: string): Promise<ApiError>;
|