@rebasepro/server-postgres 0.0.1-canary.fc811d7 → 0.9.1-canary.0de22e0
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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +43 -2
- package/dist/PostgresBootstrapper.d.ts +17 -1
- package/dist/auth/services.d.ts +16 -0
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +2502 -2624
- package/dist/index.es.js.map +1 -1
- package/dist/module-dir.d.ts +1 -0
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +46 -5
- package/dist/security/rls-enforcement.d.ts +28 -3
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +27 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +76 -2
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +15 -40
- package/src/PostgresBackendDriver.ts +183 -18
- package/src/PostgresBootstrapper.ts +86 -27
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/cli-helpers.ts +2 -20
- package/src/cli.ts +60 -0
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/module-dir.ts +7 -0
- package/src/schema/doctor-cli.ts +5 -1
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-db.ts +19 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/anonymous-grants.test.ts +71 -0
- package/src/security/policy-drift.test.ts +153 -14
- package/src/security/policy-drift.ts +128 -10
- package/src/security/rls-enforcement.ts +67 -6
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +130 -14
- package/src/services/RelationService.ts +153 -94
- package/src/services/channel-history.ts +343 -0
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +237 -28
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +34 -12
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/dist/src-Eh-CZosp.js +0 -595
- package/dist/src-Eh-CZosp.js.map +0 -1
- package/src/schema/auth-default-policies.ts +0 -125
|
@@ -4,12 +4,13 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
|
|
|
4
4
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
5
5
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
6
6
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
7
|
+
import type { ChannelRetentionRule } from "@rebasepro/types";
|
|
7
8
|
/**
|
|
8
9
|
* Auth context stored per-subscription so real-time refetches respect RLS.
|
|
9
10
|
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
10
11
|
*/
|
|
11
12
|
export interface SubscriptionAuthContext {
|
|
12
|
-
|
|
13
|
+
uid: string;
|
|
13
14
|
roles: string[];
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
@@ -24,6 +25,26 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
24
25
|
private clients;
|
|
25
26
|
private channels;
|
|
26
27
|
private presence;
|
|
28
|
+
/**
|
|
29
|
+
* Ordered, replayable history for channels that opt into it.
|
|
30
|
+
*
|
|
31
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
32
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
33
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
34
|
+
*/
|
|
35
|
+
private channelHistory?;
|
|
36
|
+
/**
|
|
37
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
38
|
+
* number and fanning the message out happen in the same order for every
|
|
39
|
+
* message on that channel.
|
|
40
|
+
*
|
|
41
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
42
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
43
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
44
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
45
|
+
* wait on each other.
|
|
46
|
+
*/
|
|
47
|
+
private channelSendQueues;
|
|
27
48
|
private presenceInterval?;
|
|
28
49
|
private static readonly PRESENCE_TIMEOUT_MS;
|
|
29
50
|
private dataService;
|
|
@@ -177,8 +198,15 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
177
198
|
/**
|
|
178
199
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
179
200
|
* The client can merge this into its cached data for instant feedback.
|
|
201
|
+
*
|
|
202
|
+
* The key columns ride along: the patch names a row by address, and the
|
|
203
|
+
* client has to find that row among the ones it cached — which carry
|
|
204
|
+
* columns and no address. The SDK holds no collection config to derive one
|
|
205
|
+
* from, so this is the only place the mapping can come from.
|
|
180
206
|
*/
|
|
181
207
|
private sendCollectionPatch;
|
|
208
|
+
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
209
|
+
private primaryKeysForPath;
|
|
182
210
|
private sendError;
|
|
183
211
|
private sendMessage;
|
|
184
212
|
/**
|
|
@@ -190,8 +218,54 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
190
218
|
joinChannel(clientId: string, channel: string): void;
|
|
191
219
|
/** Leave a broadcast channel */
|
|
192
220
|
leaveChannel(clientId: string, channel: string): void;
|
|
193
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
223
|
+
*
|
|
224
|
+
* On a channel with no retention rule this is what it always was: a
|
|
225
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
226
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
227
|
+
*
|
|
228
|
+
* On a retained channel the message is durably numbered first and only then
|
|
229
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
230
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
231
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
232
|
+
* disconnected.
|
|
233
|
+
*/
|
|
194
234
|
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void;
|
|
235
|
+
/**
|
|
236
|
+
* Number a broadcast, store it, then deliver it.
|
|
237
|
+
*
|
|
238
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
239
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
240
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
241
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
242
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
243
|
+
* that keeps clients convergent.
|
|
244
|
+
*/
|
|
245
|
+
private persistAndFanOut;
|
|
246
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
247
|
+
private fanOutBroadcast;
|
|
248
|
+
/**
|
|
249
|
+
* Install retention rules and create the tables they need.
|
|
250
|
+
*
|
|
251
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
252
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
253
|
+
* fire-and-forget path.
|
|
254
|
+
*/
|
|
255
|
+
configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void>;
|
|
256
|
+
/** Whether any channel is configured to retain messages. */
|
|
257
|
+
isChannelHistoryEnabled(): boolean;
|
|
258
|
+
/**
|
|
259
|
+
* Answer a client's catch-up request.
|
|
260
|
+
*
|
|
261
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
262
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
263
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
264
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
265
|
+
* guessing.
|
|
266
|
+
*/
|
|
267
|
+
private handleChannelHistoryRequest;
|
|
268
|
+
private sendChannelHistory;
|
|
195
269
|
/** Track presence in a channel */
|
|
196
270
|
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
|
|
197
271
|
/** Remove presence from a channel */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CollectionConfig, Relation } from "@rebasepro/types";
|
|
2
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
3
|
+
/**
|
|
4
|
+
* Turning a drizzle result into a row we serve.
|
|
5
|
+
*
|
|
6
|
+
* There are two shapes, and they are the same walk. Both take a row whose
|
|
7
|
+
* relation fields hold nested objects, both unwrap junction rows to reach the
|
|
8
|
+
* target behind them, and both leave every other column alone. They differ only
|
|
9
|
+
* in what they put where the relation was:
|
|
10
|
+
*
|
|
11
|
+
* - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
|
|
12
|
+
* target's values. This is what the admin renders.
|
|
13
|
+
* - `"inline"` — the target's own columns, flat. This is what REST serves.
|
|
14
|
+
*
|
|
15
|
+
* They used to be two functions that happened to agree, and the agreement was
|
|
16
|
+
* not enforced by anything: the row-identity bug had to be fixed five times
|
|
17
|
+
* across differently-shaped copies of this walk, and one of the copies was
|
|
18
|
+
* dead code nobody had noticed. Whatever the next cross-cutting change is, it
|
|
19
|
+
* is one edit here.
|
|
20
|
+
*/
|
|
21
|
+
export type RelationStyle = "ref" | "inline";
|
|
22
|
+
/**
|
|
23
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
24
|
+
*
|
|
25
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
26
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
27
|
+
* to unwrap that same level back out.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isJunctionRelation(relation: Relation): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The address a relation ref points at.
|
|
32
|
+
*
|
|
33
|
+
* The whole key, not its first column: a composite-keyed target addressed by
|
|
34
|
+
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
35
|
+
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
36
|
+
* array — taking down the parent's fetch over a relation it may not even have
|
|
37
|
+
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
38
|
+
* beats no rows at all.
|
|
39
|
+
*/
|
|
40
|
+
export declare function relationTargetAddress(targetRow: Record<string, unknown>, targetCollection: CollectionConfig, registry: PostgresCollectionRegistry): string;
|
|
41
|
+
/**
|
|
42
|
+
* The row the admin renders: every column, with relations as references.
|
|
43
|
+
*
|
|
44
|
+
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
45
|
+
* expects real types. The row's own address is *not* among the columns — it is
|
|
46
|
+
* derived by the consumer from the collection's primary keys.
|
|
47
|
+
*/
|
|
48
|
+
export declare function toCmsRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
|
|
49
|
+
/**
|
|
50
|
+
* The row REST serves: every column under its own name, with the value Postgres
|
|
51
|
+
* returned, and relations inlined as the target's columns.
|
|
52
|
+
*
|
|
53
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
54
|
+
* declared type: a `number` property is served as a number (see
|
|
55
|
+
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
56
|
+
* JSON has its own opinions about dates that the admin's view-model does not
|
|
57
|
+
* share.
|
|
58
|
+
*
|
|
59
|
+
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
60
|
+
* the relations `include` asked for, so the row is the authority on which are
|
|
61
|
+
* actually there.
|
|
62
|
+
*/
|
|
63
|
+
export declare function toRestRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.1-canary.0de22e0",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "https://github.com/rebasepro/rebase.git",
|
|
14
14
|
"directory": "packages/server-postgres"
|
|
15
15
|
},
|
|
16
|
-
"main": "./dist/index.
|
|
16
|
+
"main": "./dist/index.es.js",
|
|
17
17
|
"module": "./dist/index.es.js",
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
19
|
"source": "src/index.ts",
|
|
@@ -29,39 +29,11 @@
|
|
|
29
29
|
"backend",
|
|
30
30
|
"rebase"
|
|
31
31
|
],
|
|
32
|
-
"jest": {
|
|
33
|
-
"transform": {
|
|
34
|
-
"^.+\\.tsx?$": "ts-jest"
|
|
35
|
-
},
|
|
36
|
-
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
|
|
37
|
-
"testPathIgnorePatterns": [
|
|
38
|
-
"/node_modules/",
|
|
39
|
-
"test/e2e/"
|
|
40
|
-
],
|
|
41
|
-
"moduleFileExtensions": [
|
|
42
|
-
"ts",
|
|
43
|
-
"tsx",
|
|
44
|
-
"js",
|
|
45
|
-
"jsx",
|
|
46
|
-
"json",
|
|
47
|
-
"node"
|
|
48
|
-
],
|
|
49
|
-
"moduleNameMapper": {
|
|
50
|
-
"^chalk$": "<rootDir>/test/mocks/chalk.cjs",
|
|
51
|
-
"^@rebasepro/client$": "<rootDir>/../client/src/index.ts",
|
|
52
|
-
"^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
|
|
53
|
-
"^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
|
|
54
|
-
"^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts",
|
|
55
|
-
"^@rebasepro/server$": "<rootDir>/../server/src/index.ts",
|
|
56
|
-
"^(\\.{1,2}/.*)\\.js$": "$1"
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
32
|
"exports": {
|
|
60
33
|
".": {
|
|
61
34
|
"types": "./dist/index.d.ts",
|
|
62
35
|
"development": "./dist/index.es.js",
|
|
63
|
-
"import": "./dist/index.es.js"
|
|
64
|
-
"require": "./dist/index.umd.js"
|
|
36
|
+
"import": "./dist/index.es.js"
|
|
65
37
|
},
|
|
66
38
|
"./package.json": "./package.json"
|
|
67
39
|
},
|
|
@@ -73,21 +45,23 @@
|
|
|
73
45
|
"dotenv": "^17.4.2",
|
|
74
46
|
"drizzle-orm": "^0.45.2",
|
|
75
47
|
"execa": "^9.6.1",
|
|
76
|
-
"hono": "^4.12.25",
|
|
77
48
|
"pg": "^8.21.0",
|
|
78
49
|
"ws": "^8.21.0",
|
|
79
|
-
"@rebasepro/
|
|
80
|
-
"@rebasepro/server": "0.
|
|
81
|
-
"@rebasepro/
|
|
82
|
-
"@rebasepro/
|
|
83
|
-
"@rebasepro/
|
|
50
|
+
"@rebasepro/codegen": "0.9.1-canary.0de22e0",
|
|
51
|
+
"@rebasepro/server": "0.9.1-canary.0de22e0",
|
|
52
|
+
"@rebasepro/common": "0.9.1-canary.0de22e0",
|
|
53
|
+
"@rebasepro/utils": "0.9.1-canary.0de22e0",
|
|
54
|
+
"@rebasepro/types": "0.9.1-canary.0de22e0"
|
|
84
55
|
},
|
|
85
56
|
"devDependencies": {
|
|
57
|
+
"@hono/node-server": "^2.0.9",
|
|
58
|
+
"@jest/globals": "^30.4.1",
|
|
86
59
|
"@types/jest": "^30.0.0",
|
|
87
60
|
"@types/node": "^25.9.3",
|
|
88
61
|
"@types/pg": "^8.20.0",
|
|
89
62
|
"@types/ws": "^8.18.1",
|
|
90
63
|
"@vitejs/plugin-react": "^6.0.2",
|
|
64
|
+
"hono": "^4.12.25",
|
|
91
65
|
"jest": "^30.4.2",
|
|
92
66
|
"ts-jest": "^29.4.11",
|
|
93
67
|
"typescript": "^6.0.3",
|
|
@@ -104,10 +78,11 @@
|
|
|
104
78
|
],
|
|
105
79
|
"scripts": {
|
|
106
80
|
"watch": "vite build --watch",
|
|
107
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
81
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
|
|
108
82
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
109
|
-
"test": "jest
|
|
83
|
+
"test": "jest",
|
|
110
84
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
111
|
-
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
|
85
|
+
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
|
|
86
|
+
"smoke:baas": "tsx scripts/smoke-baas.ts"
|
|
112
87
|
}
|
|
113
88
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
RebaseData,
|
|
18
18
|
RebaseSdkData,
|
|
19
19
|
RestFetchService,
|
|
20
|
+
SaveManyProps,
|
|
20
21
|
SaveProps,
|
|
21
22
|
TableColumnInfo,
|
|
22
23
|
TableForeignKeyInfo,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
29
30
|
import { buildPropertyCallbacks, buildSdkData, resolveCollectionRelations, updateDateAutoValues } from "@rebasepro/common";
|
|
30
31
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
32
|
+
import { deriveRowAddress } from "./services/collection-helpers";
|
|
31
33
|
import { HistoryService } from "./history/HistoryService";
|
|
32
34
|
import { mergeDeep } from "@rebasepro/utils";
|
|
33
35
|
import { logger } from "@rebasepro/server";
|
|
@@ -109,6 +111,7 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
109
111
|
executeSql: (...args: Parameters<NonNullable<DatabaseAdmin["executeSql"]>>) => this.executeSql(...args),
|
|
110
112
|
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
111
113
|
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
114
|
+
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
112
115
|
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
113
116
|
fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
|
|
114
117
|
fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
|
|
@@ -532,7 +535,8 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
532
535
|
id,
|
|
533
536
|
values,
|
|
534
537
|
collection,
|
|
535
|
-
status
|
|
538
|
+
status,
|
|
539
|
+
upsert
|
|
536
540
|
}: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
537
541
|
|
|
538
542
|
const {
|
|
@@ -545,13 +549,26 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
545
549
|
let updatedValues = values;
|
|
546
550
|
const contextForCallback = this.buildCallContext();
|
|
547
551
|
|
|
548
|
-
// Fetch previous values for callbacks AND history recording
|
|
552
|
+
// Fetch previous values for callbacks AND history recording. Same walk
|
|
553
|
+
// as the saved row the callbacks receive (`fetchOneForRest`), so
|
|
554
|
+
// `values` and `previousValues` compare like with like — a Date on one
|
|
555
|
+
// side and its ISO string on the other reads as a change that never
|
|
556
|
+
// happened.
|
|
549
557
|
let previousValuesForHistory: Partial<M> | undefined;
|
|
550
558
|
if (status === "existing" && id) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
559
|
+
try {
|
|
560
|
+
const existing = await this.dataService.getFetchService()
|
|
561
|
+
.fetchOneForRest(path, id, undefined, resolvedCollection?.databaseId);
|
|
562
|
+
if (existing) {
|
|
563
|
+
const { id: _existingId, ...existingValues } = existing;
|
|
564
|
+
previousValuesForHistory = existingValues as Partial<M>;
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
// Best-effort enrichment: callbacks and history run without
|
|
568
|
+
// previous values rather than the save failing on a read the
|
|
569
|
+
// write itself does not need (e.g. a collection whose key the
|
|
570
|
+
// registry cannot resolve).
|
|
571
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
555
572
|
}
|
|
556
573
|
}
|
|
557
574
|
|
|
@@ -616,7 +633,8 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
616
633
|
path,
|
|
617
634
|
updatedValues,
|
|
618
635
|
id,
|
|
619
|
-
resolvedCollection?.databaseId
|
|
636
|
+
resolvedCollection?.databaseId,
|
|
637
|
+
{ upsert }
|
|
620
638
|
);
|
|
621
639
|
|
|
622
640
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
@@ -649,8 +667,19 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
649
667
|
}
|
|
650
668
|
}
|
|
651
669
|
|
|
652
|
-
|
|
653
|
-
|
|
670
|
+
// The row is exactly its columns, so its address is derived, not read
|
|
671
|
+
// off it: `savedRow.id` is undefined for every table whose key is not
|
|
672
|
+
// literally named `id`, and is ordinary data for a table that has such
|
|
673
|
+
// a column without it being the key.
|
|
674
|
+
const savedId = deriveRowAddress(
|
|
675
|
+
savedRow,
|
|
676
|
+
(resolvedCollection ?? collection) as CollectionConfig,
|
|
677
|
+
this.registry
|
|
678
|
+
);
|
|
679
|
+
// `values` are the row's columns — all of them. For an `id`-keyed table
|
|
680
|
+
// that includes `id`, which used to be stripped here because it was the
|
|
681
|
+
// synthesized address rather than the column it now is.
|
|
682
|
+
const savedValues = savedRow;
|
|
654
683
|
|
|
655
684
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
656
685
|
// 1. Global callbacks first
|
|
@@ -695,7 +724,7 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
695
724
|
if (this.historyService && resolvedCollection?.history) {
|
|
696
725
|
this.historyService.recordHistory({
|
|
697
726
|
tableName: path,
|
|
698
|
-
id: savedId
|
|
727
|
+
id: savedId,
|
|
699
728
|
action: status === "new" ? "create" : "update",
|
|
700
729
|
values: savedValues as Record<string, unknown>,
|
|
701
730
|
previousValues: previousValuesForHistory as Record<string, unknown> | undefined,
|
|
@@ -707,14 +736,14 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
707
736
|
if (this._deferNotifications) {
|
|
708
737
|
this._pendingNotifications.push({
|
|
709
738
|
path,
|
|
710
|
-
id: savedId
|
|
739
|
+
id: savedId,
|
|
711
740
|
row: savedRow,
|
|
712
741
|
databaseId: resolvedCollection?.databaseId
|
|
713
742
|
});
|
|
714
743
|
} else {
|
|
715
744
|
await this.realtimeService.notifyUpdate(
|
|
716
745
|
path,
|
|
717
|
-
savedId
|
|
746
|
+
savedId,
|
|
718
747
|
savedRow,
|
|
719
748
|
resolvedCollection?.databaseId
|
|
720
749
|
);
|
|
@@ -764,13 +793,86 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
764
793
|
}
|
|
765
794
|
}
|
|
766
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Write many rows through the same pipeline as {@link save}.
|
|
798
|
+
*
|
|
799
|
+
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
800
|
+
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
801
|
+
* state. When this driver is already inside a transaction (the authenticated
|
|
802
|
+
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
803
|
+
* still atomic and still commits once.
|
|
804
|
+
*
|
|
805
|
+
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
806
|
+
* with the last write winning, exactly as separate calls would.
|
|
807
|
+
*/
|
|
808
|
+
async saveMany<M extends Record<string, unknown>>({
|
|
809
|
+
path,
|
|
810
|
+
rows,
|
|
811
|
+
collection,
|
|
812
|
+
upsert
|
|
813
|
+
}: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
814
|
+
return this.db.transaction(async (tx) => {
|
|
815
|
+
// Bind the whole batch to the transaction handle. Without this the
|
|
816
|
+
// rows would be written through `this.db` and survive a rollback.
|
|
817
|
+
const txDriver = new PostgresBackendDriver(
|
|
818
|
+
tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
|
|
819
|
+
);
|
|
820
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
821
|
+
txDriver.client = this.client;
|
|
822
|
+
// Carry the caller's notification batching through, so a bulk write
|
|
823
|
+
// nested in an outer transaction still holds its events until commit.
|
|
824
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
825
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
826
|
+
|
|
827
|
+
const saved: Record<string, unknown>[] = [];
|
|
828
|
+
|
|
829
|
+
for (let i = 0; i < rows.length; i++) {
|
|
830
|
+
const values = rows[i];
|
|
831
|
+
const id = (values as Record<string, unknown>)?.id as string | number | undefined;
|
|
832
|
+
try {
|
|
833
|
+
saved.push(await txDriver.save<M>({
|
|
834
|
+
path,
|
|
835
|
+
values,
|
|
836
|
+
// No `id` argument, deliberately: passing one selects the
|
|
837
|
+
// UPDATE path, and an import's rows usually carry a natural
|
|
838
|
+
// key for a row that does not exist yet — which would 404 on
|
|
839
|
+
// every one. Leaving the key inside `values` is what
|
|
840
|
+
// single-row `create(data, id)` does, and it inserts.
|
|
841
|
+
// Callers who want existing rows overwritten pass `upsert`.
|
|
842
|
+
collection,
|
|
843
|
+
status: "new",
|
|
844
|
+
upsert
|
|
845
|
+
}));
|
|
846
|
+
} catch (error) {
|
|
847
|
+
// One bad row in ten thousand is impossible to find from a
|
|
848
|
+
// message that only says the batch failed. Say which row, and
|
|
849
|
+
// keep the original error as the cause so its status survives.
|
|
850
|
+
const label = id !== undefined ? `id ${JSON.stringify(id)}` : "no id";
|
|
851
|
+
throw Object.assign(
|
|
852
|
+
new Error(`Row ${i} of ${rows.length} (${label}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
|
|
853
|
+
{
|
|
854
|
+
statusCode: (error as { statusCode?: number })?.statusCode,
|
|
855
|
+
code: (error as { code?: string })?.code,
|
|
856
|
+
name: (error as Error)?.name
|
|
857
|
+
}
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return saved;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
767
866
|
async delete<M extends Record<string, unknown>>({
|
|
768
867
|
row,
|
|
769
868
|
collection
|
|
770
869
|
}: DeleteProps<M>): Promise<void> {
|
|
771
870
|
|
|
772
871
|
const targetPath = row.path;
|
|
773
|
-
|
|
872
|
+
// The callbacks' `row` is the row: its columns, nothing else. The address
|
|
873
|
+
// travels beside it as `id`, so merging it in here only ever invented an
|
|
874
|
+
// `id` field for tables that have no such column.
|
|
875
|
+
const targetRow: Record<string, unknown> = { ...(row.values ?? {}) };
|
|
774
876
|
|
|
775
877
|
// Resolve from backend registry to restore callbacks lost during WebSocket serialization
|
|
776
878
|
const {
|
|
@@ -1070,6 +1172,56 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
1070
1172
|
return result.map((r: Record<string, unknown>) => r.rolname as string);
|
|
1071
1173
|
}
|
|
1072
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* Application-level roles actually in use in this project.
|
|
1177
|
+
*
|
|
1178
|
+
* Distinct from {@link fetchAvailableRoles}, which returns native
|
|
1179
|
+
* PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
|
|
1180
|
+
* are the roles the SQL editor can `SET ROLE` to. *These* are the strings
|
|
1181
|
+
* held in the users table's `roles` column, injected per-transaction as
|
|
1182
|
+
* `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
|
|
1183
|
+
* into a `SecurityRule.roles` field produces a condition no user can ever
|
|
1184
|
+
* satisfy, so the two must not be conflated.
|
|
1185
|
+
*
|
|
1186
|
+
* Roles have no registry table — they were migrated out of
|
|
1187
|
+
* `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
|
|
1188
|
+
* set is derived from what is assigned. A role that is declared in a policy
|
|
1189
|
+
* but held by nobody yet cannot be discovered here; callers that need it
|
|
1190
|
+
* should union in the roles they already know about.
|
|
1191
|
+
*/
|
|
1192
|
+
async fetchApplicationRoles(): Promise<string[]> {
|
|
1193
|
+
// The users table lives in `rebase` for a default (public) setup, but
|
|
1194
|
+
// follows the configured schema otherwise — locate it rather than
|
|
1195
|
+
// assuming. The `roles` ARRAY column is what makes it the auth table.
|
|
1196
|
+
const located = await this.executeSql(`
|
|
1197
|
+
SELECT table_schema, table_name
|
|
1198
|
+
FROM information_schema.columns
|
|
1199
|
+
WHERE column_name = 'roles'
|
|
1200
|
+
AND data_type = 'ARRAY'
|
|
1201
|
+
AND table_name = 'users'
|
|
1202
|
+
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
1203
|
+
ORDER BY (table_schema = 'rebase') DESC, table_schema
|
|
1204
|
+
LIMIT 1;
|
|
1205
|
+
`);
|
|
1206
|
+
if (located.length === 0) return [];
|
|
1207
|
+
|
|
1208
|
+
const schema = located[0].table_schema as string;
|
|
1209
|
+
const table = located[0].table_name as string;
|
|
1210
|
+
// Identifiers come from information_schema, not user input, but they
|
|
1211
|
+
// are still interpolated — quote them so odd-but-legal names survive.
|
|
1212
|
+
const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
|
|
1213
|
+
|
|
1214
|
+
const rows = await this.executeSql(`
|
|
1215
|
+
SELECT DISTINCT unnest(roles) AS role
|
|
1216
|
+
FROM ${qualified}
|
|
1217
|
+
WHERE roles IS NOT NULL
|
|
1218
|
+
ORDER BY role;
|
|
1219
|
+
`);
|
|
1220
|
+
return rows
|
|
1221
|
+
.map((r) => r.role as string)
|
|
1222
|
+
.filter((r): r is string => typeof r === "string" && r.length > 0);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1073
1225
|
async fetchCurrentDatabase(): Promise<string | undefined> {
|
|
1074
1226
|
return this.poolManager?.defaultDatabaseName;
|
|
1075
1227
|
}
|
|
@@ -1273,10 +1425,10 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
|
|
|
1273
1425
|
const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
|
|
1274
1426
|
|
|
1275
1427
|
const result = await this.delegate.db.transaction(async (tx) => {
|
|
1276
|
-
let
|
|
1277
|
-
if (!
|
|
1428
|
+
let uid = this.user?.uid;
|
|
1429
|
+
if (!uid) {
|
|
1278
1430
|
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
1279
|
-
|
|
1431
|
+
uid = "anonymous";
|
|
1280
1432
|
}
|
|
1281
1433
|
|
|
1282
1434
|
const userRoles = this.user?.roles ?? [];
|
|
@@ -1295,7 +1447,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
|
|
|
1295
1447
|
//
|
|
1296
1448
|
// Fails closed: if the switch cannot be performed, the transaction
|
|
1297
1449
|
// aborts rather than falling back to an RLS-bypassing connection.
|
|
1298
|
-
await applyAuthContext(tx, {
|
|
1450
|
+
await applyAuthContext(tx, { uid, roles: userRoles }, this.delegate.rlsUserRole);
|
|
1299
1451
|
|
|
1300
1452
|
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
1301
1453
|
const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
|
|
@@ -1334,7 +1486,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
|
|
|
1334
1486
|
*/
|
|
1335
1487
|
private injectAuthContext(unsubscribe: () => void): () => void {
|
|
1336
1488
|
const authContext = {
|
|
1337
|
-
|
|
1489
|
+
uid: this.user?.uid || "anonymous",
|
|
1338
1490
|
roles: this.user?.roles ?? []
|
|
1339
1491
|
};
|
|
1340
1492
|
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
@@ -1362,6 +1514,19 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
|
|
|
1362
1514
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
1363
1515
|
}
|
|
1364
1516
|
|
|
1517
|
+
/**
|
|
1518
|
+
* One transaction for the whole batch, rather than one per row.
|
|
1519
|
+
*
|
|
1520
|
+
* This is the point of the method: `save` opens a transaction per call, so
|
|
1521
|
+
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
1522
|
+
* round trips). Here the RLS context is established once and every row lands
|
|
1523
|
+
* or none does. Realtime notifications are already deferred to commit by
|
|
1524
|
+
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
1525
|
+
*/
|
|
1526
|
+
async saveMany<M extends Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
1527
|
+
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1365
1530
|
async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
|
|
1366
1531
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
1367
1532
|
}
|