@rebasepro/server-postgres 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
|
@@ -1,1857 +0,0 @@
|
|
|
1
|
-
import { DataService } from "./services/dataService";
|
|
2
|
-
import { BranchService } from "./services/BranchService";
|
|
3
|
-
import { RealtimeService } from "./services/realtimeService";
|
|
4
|
-
import { DatabasePoolManager } from "./databasePoolManager";
|
|
5
|
-
import { DrizzleClient } from "./interfaces";
|
|
6
|
-
import {
|
|
7
|
-
DatabaseAdmin,
|
|
8
|
-
DataDriver,
|
|
9
|
-
DeleteProps,
|
|
10
|
-
CollectionConfig,
|
|
11
|
-
FetchCollectionProps,
|
|
12
|
-
FetchOneProps,
|
|
13
|
-
ListenCollectionProps,
|
|
14
|
-
ListenOneProps,
|
|
15
|
-
RebaseCallContext,
|
|
16
|
-
RebaseClient,
|
|
17
|
-
RebaseData,
|
|
18
|
-
RebaseSdkData,
|
|
19
|
-
RestFetchService,
|
|
20
|
-
SaveManyProps,
|
|
21
|
-
SaveProps,
|
|
22
|
-
StorageSource,
|
|
23
|
-
UpdateManyProps,
|
|
24
|
-
DeleteManyProps,
|
|
25
|
-
EntityValues,
|
|
26
|
-
TableColumnInfo,
|
|
27
|
-
TableForeignKeyInfo,
|
|
28
|
-
TableJunctionInfo,
|
|
29
|
-
TableMetadata,
|
|
30
|
-
TablePolicyInfo,
|
|
31
|
-
User
|
|
32
|
-
} from "@rebasepro/types";
|
|
33
|
-
import { sql as drizzleSql } from "drizzle-orm";
|
|
34
|
-
import { buildPropertyCallbacks, buildSdkData, classifyTable, detectJunctionTables, resolveCollectionRelations, updateDateAutoValues } from "@rebasepro/common";
|
|
35
|
-
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
36
|
-
import { deriveRowAddress } from "./services/collection-helpers";
|
|
37
|
-
import { HistoryService } from "./history/HistoryService";
|
|
38
|
-
import { mergeDeep } from "@rebasepro/utils";
|
|
39
|
-
import { logger } from "@rebasepro/server";
|
|
40
|
-
import { isRoleSwitchingPermissionError } from "./utils/pg-error-utils";
|
|
41
|
-
import { applyAuthContext } from "./security/rls-enforcement";
|
|
42
|
-
import { generateSchemaCommit } from "./schema/generate-schema-commit";
|
|
43
|
-
import { readSchemaFactsFor, type Queryable } from "./schema/ensure-collection-tables";
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Has an operator opted out of database role switching entirely?
|
|
47
|
-
*
|
|
48
|
-
* `DISABLE_DB_ROLE_SWITCHING=true` is documented (README, the configuration
|
|
49
|
-
* page, the backend skill) as "run Studio SQL Editor queries as the connection
|
|
50
|
-
* owner", for deployments whose application roles have no database role behind
|
|
51
|
-
* them. It is the only sanctioned way a statement that named a role runs
|
|
52
|
-
* without it — every other route now refuses.
|
|
53
|
-
*
|
|
54
|
-
* Exact `"true"` on purpose, matching the check this replaced. `=1` and `=yes`
|
|
55
|
-
* silently do nothing, which `docs/audits/80-config-and-env.md` already records
|
|
56
|
-
* as a finding across the env surface; fixing it here alone would make this one
|
|
57
|
-
* variable disagree with the rest.
|
|
58
|
-
*/
|
|
59
|
-
export function isRoleSwitchingOptedOut(): boolean {
|
|
60
|
-
return process.env.DISABLE_DB_ROLE_SWITCHING === "true";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The role a statement will actually have run as, given the role it asked for.
|
|
65
|
-
*
|
|
66
|
-
* For the audit log, which recorded `options.role` — the *requested* role — and
|
|
67
|
-
* so restated the caller's request as though it were the outcome. The two part
|
|
68
|
-
* company exactly when {@link isRoleSwitchingOptedOut} holds, because every
|
|
69
|
-
* other divergence is now an error rather than a quiet substitution.
|
|
70
|
-
*/
|
|
71
|
-
export function effectiveSqlRole(requestedRole?: string): string {
|
|
72
|
-
if (!requestedRole) return CONNECTION_OWNER;
|
|
73
|
-
return isRoleSwitchingOptedOut() ? CONNECTION_OWNER : requestedRole;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** How {@link effectiveSqlRole} names "whatever role the connection holds". */
|
|
77
|
-
export const CONNECTION_OWNER = "<connection owner>";
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* A statement named a database role the connection cannot assume.
|
|
81
|
-
*
|
|
82
|
-
* Its own type because the tempting recovery — run it anyway, as the owner — is
|
|
83
|
-
* the one thing that must not happen. Callers that catch this should report it,
|
|
84
|
-
* not retry unscoped.
|
|
85
|
-
*/
|
|
86
|
-
export class RoleSwitchUnavailableError extends Error {
|
|
87
|
-
readonly code = "ROLE_SWITCH_UNAVAILABLE";
|
|
88
|
-
readonly role: string;
|
|
89
|
-
/** The underlying Postgres error, when the refusal came from a live attempt. */
|
|
90
|
-
readonly pgError?: unknown;
|
|
91
|
-
|
|
92
|
-
constructor(role: string, pgError?: unknown) {
|
|
93
|
-
super(
|
|
94
|
-
`Cannot execute SQL as role "${role}": this connection is not permitted to SET ROLE. ` +
|
|
95
|
-
`The statement was NOT executed — running it as the connection owner would return ` +
|
|
96
|
-
`owner-visible rows, which is a different question from the one that was asked. ` +
|
|
97
|
-
`Grant the connection user membership in "${role}", or set DISABLE_DB_ROLE_SWITCHING=true ` +
|
|
98
|
-
`to run SQL Editor queries as the connection owner.`
|
|
99
|
-
);
|
|
100
|
-
this.name = "RoleSwitchUnavailableError";
|
|
101
|
-
this.role = role;
|
|
102
|
-
this.pgError = pgError;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export class PostgresBackendDriver implements DataDriver {
|
|
107
|
-
key = "postgres";
|
|
108
|
-
initialised = true;
|
|
109
|
-
|
|
110
|
-
public dataService: DataService;
|
|
111
|
-
public realtimeService: RealtimeService;
|
|
112
|
-
public historyService?: HistoryService;
|
|
113
|
-
public branchService?: BranchService;
|
|
114
|
-
public user?: User;
|
|
115
|
-
public data: RebaseSdkData;
|
|
116
|
-
public client?: RebaseClient;
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Auto-set to `true` once a `SET LOCAL ROLE` has failed with insufficient
|
|
120
|
-
* privileges, so later statements refuse without spending the round trip
|
|
121
|
-
* to be refused again.
|
|
122
|
-
*
|
|
123
|
-
* Deliberately NOT a mirror of `DISABLE_DB_ROLE_SWITCHING`, which it used
|
|
124
|
-
* to be described as. The env var is an operator saying "run these as the
|
|
125
|
-
* connection owner"; this flag is the database saying "I cannot give you
|
|
126
|
-
* the role you asked for". The first is a decision and permits the
|
|
127
|
-
* fallback, the second is a failure and must not — see the SECURITY note
|
|
128
|
-
* in {@link executeSql}.
|
|
129
|
-
*/
|
|
130
|
-
private _roleSwitchingUnavailable = false;
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Restricted role that authenticated (user-context) requests run as (via
|
|
134
|
-
* `SET LOCAL ROLE`) so RLS binds every statement — reads *and* writes. Set
|
|
135
|
-
* by the bootstrapper after posture detection: defined when the connection
|
|
136
|
-
* would otherwise bypass RLS (superuser / BYPASSRLS / table owner),
|
|
137
|
-
* undefined when RLS already applies natively. The base (server-context)
|
|
138
|
-
* driver never switches — it is the trusted owner plane (auth flows,
|
|
139
|
-
* migrations, `dataAsAdmin`).
|
|
140
|
-
*/
|
|
141
|
-
public rlsUserRole?: string;
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* When true, realtime notifications are deferred until after the
|
|
145
|
-
* wrapping transaction commits. Set by `withAuth` → `withTransaction`.
|
|
146
|
-
*/
|
|
147
|
-
_deferNotifications = false;
|
|
148
|
-
_pendingNotifications: Array<{
|
|
149
|
-
path: string;
|
|
150
|
-
id: string;
|
|
151
|
-
row: Record<string, unknown> | null;
|
|
152
|
-
databaseId?: string;
|
|
153
|
-
}> = [];
|
|
154
|
-
|
|
155
|
-
constructor(
|
|
156
|
-
public db: DrizzleClient,
|
|
157
|
-
realtimeService: RealtimeService,
|
|
158
|
-
public readonly registry: PostgresCollectionRegistry,
|
|
159
|
-
user?: User,
|
|
160
|
-
public poolManager?: DatabasePoolManager,
|
|
161
|
-
historyService?: HistoryService
|
|
162
|
-
) {
|
|
163
|
-
this.dataService = new DataService(db, registry);
|
|
164
|
-
this.realtimeService = realtimeService;
|
|
165
|
-
this.historyService = historyService;
|
|
166
|
-
this.user = user;
|
|
167
|
-
this.data = buildSdkData(this);
|
|
168
|
-
|
|
169
|
-
// Initialize BranchService when adminConnectionString is configured
|
|
170
|
-
if (poolManager) {
|
|
171
|
-
this.branchService = new BranchService(db, poolManager);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Typed admin capabilities (SQLAdmin + SchemaAdmin + BranchAdmin).
|
|
178
|
-
* Implemented as a getter so method references are resolved at call-time,
|
|
179
|
-
* allowing test spies applied after construction to take effect.
|
|
180
|
-
*/
|
|
181
|
-
get admin(): DatabaseAdmin {
|
|
182
|
-
return {
|
|
183
|
-
executeSql: (...args: Parameters<NonNullable<DatabaseAdmin["executeSql"]>>) => this.executeSql(...args),
|
|
184
|
-
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
185
|
-
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
186
|
-
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
187
|
-
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
188
|
-
fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
|
|
189
|
-
fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
|
|
190
|
-
// Planning a schema change is engine-specific — it renders DDL, a
|
|
191
|
-
// Drizzle schema and the declarative SQL artifacts — so it lives
|
|
192
|
-
// here and the server detects it structurally, the same way it
|
|
193
|
-
// detects SQL. Planning only: applying is `executeSql` above, and
|
|
194
|
-
// committing belongs to whatever holds the repository.
|
|
195
|
-
// Planned against what this database actually has, not against an
|
|
196
|
-
// empty one. Whether a NOT NULL can be added comes down to whether
|
|
197
|
-
// the table holds rows, and whether an enum value will land comes
|
|
198
|
-
// down to the values the type already carries — neither is knowable
|
|
199
|
-
// from the collections, and both decide whether the statements this
|
|
200
|
-
// returns are accepted or rejected by the database they name.
|
|
201
|
-
planSchemaChange: async (before, after, options) => generateSchemaCommit({
|
|
202
|
-
before: before as CollectionConfig[],
|
|
203
|
-
after: after as CollectionConfig[],
|
|
204
|
-
paths: options?.paths,
|
|
205
|
-
existing: await readSchemaFactsFor(
|
|
206
|
-
this.schemaFactsQueryable(),
|
|
207
|
-
after as CollectionConfig[]
|
|
208
|
-
)
|
|
209
|
-
}),
|
|
210
|
-
// Branch operations (only available when poolManager is configured)
|
|
211
|
-
...(this.branchService ? {
|
|
212
|
-
createBranch: this.branchService.createBranch.bind(this.branchService),
|
|
213
|
-
deleteBranch: this.branchService.deleteBranch.bind(this.branchService),
|
|
214
|
-
listBranches: this.branchService.listBranches.bind(this.branchService),
|
|
215
|
-
getBranchInfo: this.branchService.getBranchInfo.bind(this.branchService)
|
|
216
|
-
} : {})
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* The catalogue-reading shim the schema planner wants.
|
|
222
|
-
*
|
|
223
|
-
* Text in, rows out — every statement it issues is a catalogue read keyed by
|
|
224
|
-
* schema name, and schema names are identifiers rather than bindable values,
|
|
225
|
-
* so there is nothing to parameterise. Runs on the driver's own handle, which
|
|
226
|
-
* is the connection whose privileges are already known to work.
|
|
227
|
-
*/
|
|
228
|
-
private schemaFactsQueryable(): Queryable {
|
|
229
|
-
return {
|
|
230
|
-
query: async <T>(text: string): Promise<{ rows: T[] }> => {
|
|
231
|
-
const result = await this.db.execute(drizzleSql.raw(text));
|
|
232
|
-
const rows = (result as unknown as { rows?: T[] }).rows;
|
|
233
|
-
return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* REST-optimised fetch service (include-aware eager-loading).
|
|
240
|
-
* Delegates to the underlying FetchService (include-aware eager loading),
|
|
241
|
-
* then runs the afterRead pipeline on the results. The raw FetchService does
|
|
242
|
-
* NOT run callbacks, so masking must be applied here — otherwise every
|
|
243
|
-
* REST/SDK read leaks unmasked data (see {@link applyAfterReadForRest}).
|
|
244
|
-
*/
|
|
245
|
-
get restFetchService(): RestFetchService {
|
|
246
|
-
const raw = this.dataService.getFetchService();
|
|
247
|
-
return {
|
|
248
|
-
fetchCollectionForRest: async (collectionPath, options, include) => {
|
|
249
|
-
const rows = await raw.fetchCollectionForRest(collectionPath, options, include);
|
|
250
|
-
return this.applyAfterReadForRest(rows, collectionPath);
|
|
251
|
-
},
|
|
252
|
-
fetchOneForRest: async (collectionPath, id, include, databaseId) => {
|
|
253
|
-
const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId);
|
|
254
|
-
if (!row) return row;
|
|
255
|
-
const [masked] = await this.applyAfterReadForRest([row], collectionPath);
|
|
256
|
-
return masked;
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Build the context handed to every collection callback.
|
|
263
|
-
*
|
|
264
|
-
* Note `data: this.data` — `this` is whichever driver is running the
|
|
265
|
-
* operation, so the callback's data plane inherits that driver's privilege.
|
|
266
|
-
* On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
|
|
267
|
-
* constructs a fresh base driver bound to the RLS-scoped transaction and
|
|
268
|
-
* runs the operation on it, so `this.data` speaks through that connection
|
|
269
|
-
* and policies apply. On server-context work `this` is the base driver on
|
|
270
|
-
* the owner connection, and they do not. Pinned by the
|
|
271
|
-
* `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
|
|
272
|
-
* suite, because it is the kind of property that is easy to break from a
|
|
273
|
-
* distance and impossible to notice.
|
|
274
|
-
*
|
|
275
|
-
* Previously returned through `as unknown as RebaseCallContext`, which
|
|
276
|
-
* disabled checking for the whole object and let `driver` — documented in
|
|
277
|
-
* the callbacks guide — sit on the runtime context while absent from the
|
|
278
|
-
* contract. Both are declared now, so this is a plain typed return.
|
|
279
|
-
*/
|
|
280
|
-
private buildCallContext(): RebaseCallContext {
|
|
281
|
-
return {
|
|
282
|
-
user: this.user,
|
|
283
|
-
driver: this,
|
|
284
|
-
data: this.data,
|
|
285
|
-
client: this.client as RebaseCallContext["client"],
|
|
286
|
-
storageSource: this.client?.storage as StorageSource
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private resolveCollectionCallbacks<M extends Record<string, unknown>>(collection: CollectionConfig<M> | undefined, path: string) {
|
|
291
|
-
if (!collection && !path) return {
|
|
292
|
-
collection: undefined,
|
|
293
|
-
callbacks: undefined,
|
|
294
|
-
globalCallbacks: undefined,
|
|
295
|
-
propertyCallbacks: undefined
|
|
296
|
-
};
|
|
297
|
-
const registryCollection = this.registry?.getCollectionByPath(path);
|
|
298
|
-
const resolvedCollection = registryCollection
|
|
299
|
-
? {
|
|
300
|
-
...collection,
|
|
301
|
-
...registryCollection
|
|
302
|
-
} as CollectionConfig<M>
|
|
303
|
-
: collection as CollectionConfig<M>;
|
|
304
|
-
|
|
305
|
-
const callbacks = resolvedCollection?.callbacks;
|
|
306
|
-
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
307
|
-
const properties = resolvedCollection?.properties;
|
|
308
|
-
let propertyCallbacks;
|
|
309
|
-
if (properties) {
|
|
310
|
-
propertyCallbacks = buildPropertyCallbacks(properties);
|
|
311
|
-
}
|
|
312
|
-
return {
|
|
313
|
-
collection: resolvedCollection,
|
|
314
|
-
callbacks,
|
|
315
|
-
globalCallbacks,
|
|
316
|
-
propertyCallbacks
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Run the three-tier afterRead pipeline (global → collection → property) on a
|
|
322
|
-
* single row for a collection whose callbacks have already been resolved.
|
|
323
|
-
*/
|
|
324
|
-
private async applyAfterReadToRow(
|
|
325
|
-
row: Record<string, unknown>,
|
|
326
|
-
path: string,
|
|
327
|
-
resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]>,
|
|
328
|
-
contextForCallback: RebaseCallContext
|
|
329
|
-
): Promise<Record<string, unknown>> {
|
|
330
|
-
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = resolved;
|
|
331
|
-
let out = row;
|
|
332
|
-
if (globalCallbacks?.afterRead) {
|
|
333
|
-
out = await globalCallbacks.afterRead({
|
|
334
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
335
|
-
path, row: out, context: contextForCallback
|
|
336
|
-
}) ?? out;
|
|
337
|
-
}
|
|
338
|
-
if (callbacks?.afterRead) {
|
|
339
|
-
out = await callbacks.afterRead({
|
|
340
|
-
collection: resolvedCollection as CollectionConfig,
|
|
341
|
-
path, row: out, context: contextForCallback
|
|
342
|
-
}) ?? out;
|
|
343
|
-
}
|
|
344
|
-
if (propertyCallbacks?.afterRead) {
|
|
345
|
-
out = await propertyCallbacks.afterRead({
|
|
346
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
347
|
-
path, row: out, context: contextForCallback
|
|
348
|
-
}) ?? out;
|
|
349
|
-
}
|
|
350
|
-
return out;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
private static hasAfterRead(resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]>): boolean {
|
|
354
|
-
return !!(resolved.globalCallbacks?.afterRead || resolved.callbacks?.afterRead || resolved.propertyCallbacks?.afterRead);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Apply afterRead to REST/SDK read results.
|
|
359
|
-
*
|
|
360
|
-
* The REST / `include` path fetches rows through the raw fetch service, which
|
|
361
|
-
* does NOT run callbacks — so without this, `afterRead` transforms (e.g. PII
|
|
362
|
-
* masking) are silently skipped on every SDK/REST read, leaking raw data.
|
|
363
|
-
* This choke point guarantees afterRead runs there too, matching the driver's
|
|
364
|
-
* fetchCollection/fetchOne paths.
|
|
365
|
-
*
|
|
366
|
-
* It also masks embedded relation data one level deep by running the TARGET
|
|
367
|
-
* collection's afterRead (so `post.author.email` is masked by the authors
|
|
368
|
-
* collection, not left raw).
|
|
369
|
-
*/
|
|
370
|
-
async applyAfterReadForRest(
|
|
371
|
-
rows: Record<string, unknown>[],
|
|
372
|
-
path: string
|
|
373
|
-
): Promise<Record<string, unknown>[]> {
|
|
374
|
-
if (!rows || rows.length === 0) return rows;
|
|
375
|
-
|
|
376
|
-
const resolved = this.resolveCollectionCallbacks(undefined, path);
|
|
377
|
-
const contextForCallback = this.buildCallContext();
|
|
378
|
-
const hasOwn = PostgresBackendDriver.hasAfterRead(resolved);
|
|
379
|
-
|
|
380
|
-
// Resolve embedded relation targets (relationKey -> { path, resolved callbacks })
|
|
381
|
-
// once, keeping only those whose target collection actually has an afterRead.
|
|
382
|
-
const relationTargets: Record<string, { path: string; resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]> }> = {};
|
|
383
|
-
if (resolved.collection) {
|
|
384
|
-
try {
|
|
385
|
-
const rels = resolveCollectionRelations(resolved.collection as CollectionConfig);
|
|
386
|
-
for (const [key, rel] of Object.entries(rels)) {
|
|
387
|
-
const target = typeof (rel as { target?: unknown }).target === "function"
|
|
388
|
-
? (rel as { target: () => CollectionConfig }).target()
|
|
389
|
-
: undefined;
|
|
390
|
-
const targetPath = target?.slug;
|
|
391
|
-
if (!targetPath) continue;
|
|
392
|
-
const targetResolved = this.resolveCollectionCallbacks(undefined, targetPath);
|
|
393
|
-
if (PostgresBackendDriver.hasAfterRead(targetResolved)) {
|
|
394
|
-
relationTargets[key] = { path: targetPath, resolved: targetResolved };
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
} catch {
|
|
398
|
-
// Ignore relation resolution errors (e.g. incomplete config during setup)
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
const relKeys = Object.keys(relationTargets);
|
|
402
|
-
|
|
403
|
-
if (!hasOwn && relKeys.length === 0) return rows;
|
|
404
|
-
|
|
405
|
-
const maskEmbedded = async (value: unknown, target: { path: string; resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]> }): Promise<unknown> => {
|
|
406
|
-
if (Array.isArray(value)) {
|
|
407
|
-
return Promise.all(value.map((v) => maskEmbedded(v, target)));
|
|
408
|
-
}
|
|
409
|
-
if (!value || typeof value !== "object") return value;
|
|
410
|
-
const obj = value as Record<string, unknown>;
|
|
411
|
-
// A pre-fetched relation payload may nest the row under `.data`.
|
|
412
|
-
if (obj.__type === "relation" && obj.data && typeof obj.data === "object") {
|
|
413
|
-
return { ...obj, data: await this.applyAfterReadToRow(obj.data as Record<string, unknown>, target.path, target.resolved, contextForCallback) };
|
|
414
|
-
}
|
|
415
|
-
// A bare reference pointer carries no row data to mask.
|
|
416
|
-
if (obj.__type === "reference") return obj;
|
|
417
|
-
return this.applyAfterReadToRow(obj, target.path, target.resolved, contextForCallback);
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
return Promise.all(rows.map(async (row) => {
|
|
421
|
-
let out = hasOwn ? await this.applyAfterReadToRow(row, path, resolved, contextForCallback) : row;
|
|
422
|
-
for (const key of relKeys) {
|
|
423
|
-
if (out[key] === undefined || out[key] === null) continue;
|
|
424
|
-
out = { ...out, [key]: await maskEmbedded(out[key], relationTargets[key]) };
|
|
425
|
-
}
|
|
426
|
-
return out;
|
|
427
|
-
}));
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
async fetchCollection<M extends Record<string, unknown>>({
|
|
431
|
-
path,
|
|
432
|
-
collection,
|
|
433
|
-
filter,
|
|
434
|
-
limit,
|
|
435
|
-
offset,
|
|
436
|
-
startAfter,
|
|
437
|
-
orderBy,
|
|
438
|
-
searchString,
|
|
439
|
-
order,
|
|
440
|
-
vectorSearch
|
|
441
|
-
}: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
|
|
442
|
-
|
|
443
|
-
const rows = await this.dataService.fetchCollection<M>(path, {
|
|
444
|
-
filter,
|
|
445
|
-
orderBy,
|
|
446
|
-
order,
|
|
447
|
-
limit,
|
|
448
|
-
offset,
|
|
449
|
-
startAfter: startAfter as Record<string, unknown> | undefined,
|
|
450
|
-
databaseId: collection?.databaseId,
|
|
451
|
-
searchString,
|
|
452
|
-
vectorSearch
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
const {
|
|
456
|
-
collection: resolvedCollection,
|
|
457
|
-
callbacks,
|
|
458
|
-
globalCallbacks,
|
|
459
|
-
propertyCallbacks
|
|
460
|
-
} = this.resolveCollectionCallbacks(collection, path);
|
|
461
|
-
|
|
462
|
-
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
463
|
-
const contextForCallback = this.buildCallContext();
|
|
464
|
-
return Promise.all(rows.map(async (row) => {
|
|
465
|
-
let fetched = row;
|
|
466
|
-
// 1. Global callbacks first
|
|
467
|
-
if (globalCallbacks?.afterRead) {
|
|
468
|
-
fetched = await globalCallbacks.afterRead({
|
|
469
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
470
|
-
path,
|
|
471
|
-
row: fetched,
|
|
472
|
-
context: contextForCallback
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
// 2. Collection callbacks second
|
|
476
|
-
if (callbacks?.afterRead) {
|
|
477
|
-
fetched = await callbacks.afterRead({
|
|
478
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
479
|
-
path,
|
|
480
|
-
row: fetched,
|
|
481
|
-
context: contextForCallback
|
|
482
|
-
}) ?? fetched;
|
|
483
|
-
}
|
|
484
|
-
// 3. Property callbacks third
|
|
485
|
-
if (propertyCallbacks?.afterRead) {
|
|
486
|
-
fetched = await propertyCallbacks.afterRead({
|
|
487
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
488
|
-
path,
|
|
489
|
-
row: fetched,
|
|
490
|
-
context: contextForCallback
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
return fetched;
|
|
494
|
-
}));
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
return rows;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
listenCollection<M extends Record<string, unknown>>({
|
|
501
|
-
path,
|
|
502
|
-
collection,
|
|
503
|
-
filter,
|
|
504
|
-
limit,
|
|
505
|
-
offset,
|
|
506
|
-
startAfter,
|
|
507
|
-
orderBy,
|
|
508
|
-
searchString,
|
|
509
|
-
order,
|
|
510
|
-
onUpdate,
|
|
511
|
-
onError
|
|
512
|
-
}: ListenCollectionProps<M>): () => void {
|
|
513
|
-
|
|
514
|
-
const subscriptionId = this.generateSubscriptionId();
|
|
515
|
-
|
|
516
|
-
// Type-adapter wrapper: RealtimeService expects a union callback signature
|
|
517
|
-
const callbackWrapper = (rows: Record<string, unknown>[]) => {
|
|
518
|
-
onUpdate(rows);
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
// Store the subscription in RealtimeService properly using the new public method
|
|
522
|
-
this.realtimeService.registerDataDriverSubscription(subscriptionId, {
|
|
523
|
-
clientId: "driver",
|
|
524
|
-
type: "collection" as const,
|
|
525
|
-
path,
|
|
526
|
-
collectionRequest: {
|
|
527
|
-
filter,
|
|
528
|
-
orderBy,
|
|
529
|
-
order,
|
|
530
|
-
limit,
|
|
531
|
-
offset,
|
|
532
|
-
startAfter: startAfter as Record<string, unknown> | undefined,
|
|
533
|
-
databaseId: collection?.databaseId,
|
|
534
|
-
searchString
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
// Store the callback for this subscription
|
|
539
|
-
this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper as (data: Record<string, unknown> | Record<string, unknown>[] | null) => void);
|
|
540
|
-
|
|
541
|
-
// Send initial data immediately
|
|
542
|
-
this.fetchCollection({
|
|
543
|
-
path: path,
|
|
544
|
-
collection,
|
|
545
|
-
filter,
|
|
546
|
-
limit,
|
|
547
|
-
offset,
|
|
548
|
-
startAfter,
|
|
549
|
-
orderBy,
|
|
550
|
-
searchString,
|
|
551
|
-
order
|
|
552
|
-
}).then(rows => {
|
|
553
|
-
callbackWrapper(rows);
|
|
554
|
-
}).catch(error => {
|
|
555
|
-
if (onError) onError(error);
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
return () => {
|
|
559
|
-
this.realtimeService.removeSubscriptionCallback(subscriptionId);
|
|
560
|
-
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
async fetchOne<M extends Record<string, unknown>>({
|
|
565
|
-
path,
|
|
566
|
-
id,
|
|
567
|
-
databaseId,
|
|
568
|
-
collection
|
|
569
|
-
}: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
|
|
570
|
-
let row = await this.dataService.fetchOne<M>(
|
|
571
|
-
path,
|
|
572
|
-
id,
|
|
573
|
-
databaseId || collection?.databaseId
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
const {
|
|
577
|
-
collection: resolvedCollection,
|
|
578
|
-
callbacks,
|
|
579
|
-
globalCallbacks,
|
|
580
|
-
propertyCallbacks
|
|
581
|
-
} = this.resolveCollectionCallbacks(collection, path);
|
|
582
|
-
|
|
583
|
-
if (row && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
584
|
-
const contextForCallback = this.buildCallContext();
|
|
585
|
-
// 1. Global callbacks first
|
|
586
|
-
if (globalCallbacks?.afterRead) {
|
|
587
|
-
row = await globalCallbacks.afterRead({
|
|
588
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
589
|
-
path,
|
|
590
|
-
row,
|
|
591
|
-
context: contextForCallback
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
// 2. Collection callbacks second
|
|
595
|
-
if (callbacks?.afterRead) {
|
|
596
|
-
row = await callbacks.afterRead({
|
|
597
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
598
|
-
path,
|
|
599
|
-
row,
|
|
600
|
-
context: contextForCallback
|
|
601
|
-
}) ?? row;
|
|
602
|
-
}
|
|
603
|
-
// 3. Property callbacks third
|
|
604
|
-
if (propertyCallbacks?.afterRead) {
|
|
605
|
-
row = await propertyCallbacks.afterRead({
|
|
606
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
607
|
-
path,
|
|
608
|
-
row,
|
|
609
|
-
context: contextForCallback
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
return row;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
listenOne<M extends Record<string, unknown>>({
|
|
618
|
-
path,
|
|
619
|
-
id,
|
|
620
|
-
collection,
|
|
621
|
-
onUpdate,
|
|
622
|
-
onError
|
|
623
|
-
}: ListenOneProps<M>): () => void {
|
|
624
|
-
|
|
625
|
-
const subscriptionId = this.generateSubscriptionId();
|
|
626
|
-
const callbackWrapper = (row: Record<string, unknown> | null) => {
|
|
627
|
-
if (row)
|
|
628
|
-
onUpdate(row);
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
// Register the subscription with the RealtimeService
|
|
632
|
-
this.realtimeService.registerDataDriverSubscription(subscriptionId, {
|
|
633
|
-
clientId: "driver",
|
|
634
|
-
type: "single" as const,
|
|
635
|
-
path,
|
|
636
|
-
id
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
// Store the callback for this subscription
|
|
640
|
-
this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper as (data: Record<string, unknown> | Record<string, unknown>[] | null) => void);
|
|
641
|
-
|
|
642
|
-
// Fetch initial data
|
|
643
|
-
this.fetchOne({
|
|
644
|
-
path,
|
|
645
|
-
id,
|
|
646
|
-
collection
|
|
647
|
-
})
|
|
648
|
-
.then(row => {
|
|
649
|
-
if (row) onUpdate(row);
|
|
650
|
-
})
|
|
651
|
-
.catch(error => {
|
|
652
|
-
if (onError) onError(error as Error);
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
// Return the unsubscribe function
|
|
656
|
-
return () => {
|
|
657
|
-
this.realtimeService.removeSubscriptionCallback(subscriptionId);
|
|
658
|
-
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
async save<M extends Record<string, unknown>>({
|
|
663
|
-
path,
|
|
664
|
-
id,
|
|
665
|
-
values,
|
|
666
|
-
collection,
|
|
667
|
-
status,
|
|
668
|
-
upsert
|
|
669
|
-
}: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
670
|
-
|
|
671
|
-
const {
|
|
672
|
-
collection: resolvedCollection,
|
|
673
|
-
callbacks,
|
|
674
|
-
globalCallbacks,
|
|
675
|
-
propertyCallbacks
|
|
676
|
-
} = this.resolveCollectionCallbacks(collection, path);
|
|
677
|
-
|
|
678
|
-
let updatedValues = values;
|
|
679
|
-
const contextForCallback = this.buildCallContext();
|
|
680
|
-
|
|
681
|
-
// Fetch previous values for callbacks AND history recording. Same walk
|
|
682
|
-
// as the saved row the callbacks receive (`fetchOneForRest`), so
|
|
683
|
-
// `values` and `previousValues` compare like with like — a Date on one
|
|
684
|
-
// side and its ISO string on the other reads as a change that never
|
|
685
|
-
// happened.
|
|
686
|
-
let previousValuesForHistory: Partial<M> | undefined;
|
|
687
|
-
if (status === "existing" && id) {
|
|
688
|
-
try {
|
|
689
|
-
const existing = await this.dataService.getFetchService()
|
|
690
|
-
.fetchOneForRest(path, id, undefined, resolvedCollection?.databaseId);
|
|
691
|
-
if (existing) {
|
|
692
|
-
const { id: _existingId, ...existingValues } = existing;
|
|
693
|
-
previousValuesForHistory = existingValues as Partial<M>;
|
|
694
|
-
}
|
|
695
|
-
} catch (err) {
|
|
696
|
-
// Best-effort enrichment: callbacks and history run without
|
|
697
|
-
// previous values rather than the save failing on a read the
|
|
698
|
-
// write itself does not need (e.g. a collection whose key the
|
|
699
|
-
// registry cannot resolve).
|
|
700
|
-
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
705
|
-
// 1. Global callbacks first
|
|
706
|
-
if (globalCallbacks?.beforeSave) {
|
|
707
|
-
const result = await globalCallbacks.beforeSave({
|
|
708
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
709
|
-
path,
|
|
710
|
-
id,
|
|
711
|
-
values: updatedValues,
|
|
712
|
-
previousValues: previousValuesForHistory,
|
|
713
|
-
status,
|
|
714
|
-
context: contextForCallback
|
|
715
|
-
});
|
|
716
|
-
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// 2. Collection callbacks second
|
|
720
|
-
if (callbacks?.beforeSave) {
|
|
721
|
-
const result = await callbacks.beforeSave({
|
|
722
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
723
|
-
path,
|
|
724
|
-
id,
|
|
725
|
-
values: updatedValues,
|
|
726
|
-
previousValues: previousValuesForHistory,
|
|
727
|
-
status,
|
|
728
|
-
context: contextForCallback
|
|
729
|
-
});
|
|
730
|
-
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// 3. Property callbacks third
|
|
734
|
-
if (propertyCallbacks?.beforeSave) {
|
|
735
|
-
const result = await propertyCallbacks.beforeSave({
|
|
736
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
737
|
-
path,
|
|
738
|
-
id,
|
|
739
|
-
values: updatedValues,
|
|
740
|
-
previousValues: previousValuesForHistory,
|
|
741
|
-
status,
|
|
742
|
-
context: contextForCallback
|
|
743
|
-
});
|
|
744
|
-
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// Apply autoValue timestamps (on_create / on_update) at the application layer.
|
|
750
|
-
// This handles updated_at fields for all writes that flow through the Rebase backend.
|
|
751
|
-
if (resolvedCollection?.properties) {
|
|
752
|
-
updatedValues = updateDateAutoValues({
|
|
753
|
-
inputValues: updatedValues,
|
|
754
|
-
properties: resolvedCollection.properties,
|
|
755
|
-
status: status ?? "new",
|
|
756
|
-
timestampNowValue: new Date()
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
try {
|
|
761
|
-
let savedRow = await this.dataService.save<M>(
|
|
762
|
-
path,
|
|
763
|
-
updatedValues,
|
|
764
|
-
id,
|
|
765
|
-
resolvedCollection?.databaseId,
|
|
766
|
-
{ upsert }
|
|
767
|
-
);
|
|
768
|
-
|
|
769
|
-
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
770
|
-
// 1. Global callbacks first
|
|
771
|
-
if (globalCallbacks?.afterRead) {
|
|
772
|
-
savedRow = await globalCallbacks.afterRead({
|
|
773
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
774
|
-
path,
|
|
775
|
-
row: savedRow,
|
|
776
|
-
context: contextForCallback
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
// 2. Collection callbacks second
|
|
780
|
-
if (callbacks?.afterRead) {
|
|
781
|
-
savedRow = await callbacks.afterRead({
|
|
782
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
783
|
-
path,
|
|
784
|
-
row: savedRow,
|
|
785
|
-
context: contextForCallback
|
|
786
|
-
}) ?? savedRow;
|
|
787
|
-
}
|
|
788
|
-
// 3. Property callbacks third
|
|
789
|
-
if (propertyCallbacks?.afterRead) {
|
|
790
|
-
savedRow = await propertyCallbacks.afterRead({
|
|
791
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
792
|
-
path,
|
|
793
|
-
row: savedRow,
|
|
794
|
-
context: contextForCallback
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// The row is exactly its columns, so its address is derived, not read
|
|
800
|
-
// off it: `savedRow.id` is undefined for every table whose key is not
|
|
801
|
-
// literally named `id`, and is ordinary data for a table that has such
|
|
802
|
-
// a column without it being the key.
|
|
803
|
-
const savedId = deriveRowAddress(
|
|
804
|
-
savedRow,
|
|
805
|
-
(resolvedCollection ?? collection) as CollectionConfig,
|
|
806
|
-
this.registry
|
|
807
|
-
);
|
|
808
|
-
// `values` are the row's columns — all of them. For an `id`-keyed table
|
|
809
|
-
// that includes `id`, which used to be stripped here because it was the
|
|
810
|
-
// synthesized address rather than the column it now is.
|
|
811
|
-
const savedValues = savedRow;
|
|
812
|
-
|
|
813
|
-
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
814
|
-
// 1. Global callbacks first
|
|
815
|
-
if (globalCallbacks?.afterSave) {
|
|
816
|
-
await globalCallbacks.afterSave({
|
|
817
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
818
|
-
path,
|
|
819
|
-
id: savedId,
|
|
820
|
-
values: savedValues,
|
|
821
|
-
previousValues: previousValuesForHistory,
|
|
822
|
-
status,
|
|
823
|
-
context: contextForCallback
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
// 2. Collection callbacks second
|
|
827
|
-
if (callbacks?.afterSave) {
|
|
828
|
-
await callbacks.afterSave({
|
|
829
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
830
|
-
path,
|
|
831
|
-
id: savedId,
|
|
832
|
-
values: savedValues as Partial<M>,
|
|
833
|
-
previousValues: previousValuesForHistory,
|
|
834
|
-
status,
|
|
835
|
-
context: contextForCallback
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
// 3. Property callbacks third
|
|
839
|
-
if (propertyCallbacks?.afterSave) {
|
|
840
|
-
await propertyCallbacks.afterSave({
|
|
841
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
842
|
-
path,
|
|
843
|
-
id: savedId,
|
|
844
|
-
values: savedValues,
|
|
845
|
-
previousValues: previousValuesForHistory,
|
|
846
|
-
status,
|
|
847
|
-
context: contextForCallback
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// Record row history (fire-and-forget, never blocks the save)
|
|
853
|
-
if (this.historyService && resolvedCollection?.history) {
|
|
854
|
-
this.historyService.recordHistory({
|
|
855
|
-
tableName: path,
|
|
856
|
-
id: savedId,
|
|
857
|
-
action: status === "new" ? "create" : "update",
|
|
858
|
-
values: savedValues as Record<string, unknown>,
|
|
859
|
-
previousValues: previousValuesForHistory as Record<string, unknown> | undefined,
|
|
860
|
-
updatedBy: this.user?.uid
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Notify real-time subscribers (deferred if inside a transaction)
|
|
865
|
-
if (this._deferNotifications) {
|
|
866
|
-
this._pendingNotifications.push({
|
|
867
|
-
path,
|
|
868
|
-
id: savedId,
|
|
869
|
-
row: savedRow,
|
|
870
|
-
databaseId: resolvedCollection?.databaseId
|
|
871
|
-
});
|
|
872
|
-
} else {
|
|
873
|
-
await this.realtimeService.notifyUpdate(
|
|
874
|
-
path,
|
|
875
|
-
savedId,
|
|
876
|
-
savedRow,
|
|
877
|
-
resolvedCollection?.databaseId
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return savedRow;
|
|
882
|
-
} catch (error) {
|
|
883
|
-
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
884
|
-
// 1. Global callbacks first
|
|
885
|
-
if (globalCallbacks?.afterSaveError) {
|
|
886
|
-
await globalCallbacks.afterSaveError({
|
|
887
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
888
|
-
path,
|
|
889
|
-
id: id || "unknown",
|
|
890
|
-
values: updatedValues,
|
|
891
|
-
previousValues: undefined,
|
|
892
|
-
status,
|
|
893
|
-
context: contextForCallback
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
// 2. Collection callbacks second
|
|
897
|
-
if (callbacks?.afterSaveError) {
|
|
898
|
-
await callbacks.afterSaveError({
|
|
899
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
900
|
-
path,
|
|
901
|
-
id: id || "unknown",
|
|
902
|
-
values: updatedValues,
|
|
903
|
-
previousValues: undefined,
|
|
904
|
-
status,
|
|
905
|
-
context: contextForCallback
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
// 3. Property callbacks third
|
|
909
|
-
if (propertyCallbacks?.afterSaveError) {
|
|
910
|
-
await propertyCallbacks.afterSaveError({
|
|
911
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
912
|
-
path,
|
|
913
|
-
id: id || "unknown",
|
|
914
|
-
values: updatedValues,
|
|
915
|
-
previousValues: undefined,
|
|
916
|
-
status,
|
|
917
|
-
context: contextForCallback
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
throw error;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
/**
|
|
926
|
-
* Write many rows through the same pipeline as {@link save}.
|
|
927
|
-
*
|
|
928
|
-
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
929
|
-
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
930
|
-
* state. When this driver is already inside a transaction (the authenticated
|
|
931
|
-
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
932
|
-
* still atomic and still commits once.
|
|
933
|
-
*
|
|
934
|
-
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
935
|
-
* with the last write winning, exactly as separate calls would.
|
|
936
|
-
*/
|
|
937
|
-
async saveMany<M extends Record<string, unknown>>({
|
|
938
|
-
path,
|
|
939
|
-
rows,
|
|
940
|
-
collection,
|
|
941
|
-
upsert
|
|
942
|
-
}: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
943
|
-
return this.db.transaction(async (tx) => {
|
|
944
|
-
// Bind the whole batch to the transaction handle. Without this the
|
|
945
|
-
// rows would be written through `this.db` and survive a rollback.
|
|
946
|
-
const txDriver = new PostgresBackendDriver(
|
|
947
|
-
tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
|
|
948
|
-
);
|
|
949
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
950
|
-
txDriver.client = this.client;
|
|
951
|
-
// Carry the caller's notification batching through, so a bulk write
|
|
952
|
-
// nested in an outer transaction still holds its events until commit.
|
|
953
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
954
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
955
|
-
|
|
956
|
-
const saved: Record<string, unknown>[] = [];
|
|
957
|
-
|
|
958
|
-
for (let i = 0; i < rows.length; i++) {
|
|
959
|
-
const values = rows[i];
|
|
960
|
-
const id = (values as Record<string, unknown>)?.id as string | number | undefined;
|
|
961
|
-
try {
|
|
962
|
-
saved.push(await txDriver.save<M>({
|
|
963
|
-
path,
|
|
964
|
-
values,
|
|
965
|
-
// No `id` argument, deliberately: passing one selects the
|
|
966
|
-
// UPDATE path, and an import's rows usually carry a natural
|
|
967
|
-
// key for a row that does not exist yet — which would 404 on
|
|
968
|
-
// every one. Leaving the key inside `values` is what
|
|
969
|
-
// single-row `create(data, id)` does, and it inserts.
|
|
970
|
-
// Callers who want existing rows overwritten pass `upsert`.
|
|
971
|
-
collection,
|
|
972
|
-
status: "new",
|
|
973
|
-
upsert
|
|
974
|
-
}));
|
|
975
|
-
} catch (error) {
|
|
976
|
-
// One bad row in ten thousand is impossible to find from a
|
|
977
|
-
// message that only says the batch failed. Say which row, and
|
|
978
|
-
// keep the original error as the cause so its status survives.
|
|
979
|
-
const label = id !== undefined ? `id ${JSON.stringify(id)}` : "no id";
|
|
980
|
-
throw Object.assign(
|
|
981
|
-
new Error(`Row ${i} of ${rows.length} (${label}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
|
|
982
|
-
{
|
|
983
|
-
statusCode: (error as { statusCode?: number })?.statusCode,
|
|
984
|
-
code: (error as { code?: string })?.code,
|
|
985
|
-
name: (error as Error)?.name
|
|
986
|
-
}
|
|
987
|
-
);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
return saved;
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* Update many rows through the same pipeline as {@link save}, in one
|
|
997
|
-
* transaction.
|
|
998
|
-
*
|
|
999
|
-
* Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
|
|
1000
|
-
* same deferred notifications, same per-row error labelling — but it calls
|
|
1001
|
-
* `save` with an explicit `id` and `status: "existing"`, which is precisely
|
|
1002
|
-
* what `saveMany` cannot do: that one passes `status: "new"` and keeps the
|
|
1003
|
-
* key inside `values`, so it inserts or upserts and can never target a
|
|
1004
|
-
* particular row.
|
|
1005
|
-
*
|
|
1006
|
-
* All-or-nothing, so an id matching no row aborts the batch. A partial
|
|
1007
|
-
* update is the outcome with no good recovery: the caller cannot tell which
|
|
1008
|
-
* half landed without re-reading everything.
|
|
1009
|
-
*/
|
|
1010
|
-
async updateMany<M extends Record<string, unknown>>({
|
|
1011
|
-
path,
|
|
1012
|
-
updates,
|
|
1013
|
-
collection
|
|
1014
|
-
}: UpdateManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
1015
|
-
return this.db.transaction(async (tx) => {
|
|
1016
|
-
const txDriver = new PostgresBackendDriver(
|
|
1017
|
-
tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
|
|
1018
|
-
);
|
|
1019
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
1020
|
-
txDriver.client = this.client;
|
|
1021
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
1022
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
1023
|
-
|
|
1024
|
-
const saved: Record<string, unknown>[] = [];
|
|
1025
|
-
|
|
1026
|
-
for (let i = 0; i < updates.length; i++) {
|
|
1027
|
-
const { id, values } = updates[i];
|
|
1028
|
-
try {
|
|
1029
|
-
// Read first so a missing row is a 404 rather than a silent
|
|
1030
|
-
// no-op. `save` with status "existing" would otherwise write
|
|
1031
|
-
// an UPDATE that matches nothing and report success.
|
|
1032
|
-
const existing = await txDriver.fetchOne({
|
|
1033
|
-
path,
|
|
1034
|
-
id: String(id),
|
|
1035
|
-
collection: collection as CollectionConfig
|
|
1036
|
-
});
|
|
1037
|
-
if (!existing) {
|
|
1038
|
-
throw Object.assign(new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
1039
|
-
statusCode: 404,
|
|
1040
|
-
code: "NOT_FOUND"
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
saved.push(await txDriver.save<M>({
|
|
1045
|
-
path,
|
|
1046
|
-
id: String(id),
|
|
1047
|
-
values,
|
|
1048
|
-
collection,
|
|
1049
|
-
status: "existing"
|
|
1050
|
-
}));
|
|
1051
|
-
} catch (error) {
|
|
1052
|
-
// Say which entry, as saveMany does: "the batch failed" is
|
|
1053
|
-
// unactionable at a thousand rows.
|
|
1054
|
-
throw Object.assign(
|
|
1055
|
-
new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
|
|
1056
|
-
{
|
|
1057
|
-
statusCode: (error as { statusCode?: number })?.statusCode,
|
|
1058
|
-
code: (error as { code?: string })?.code,
|
|
1059
|
-
name: (error as Error)?.name
|
|
1060
|
-
}
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
return saved;
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
/**
|
|
1070
|
-
* Delete many rows in one transaction, running the full delete pipeline —
|
|
1071
|
-
* `beforeDelete`, the delete, `afterDelete` — for each.
|
|
1072
|
-
*
|
|
1073
|
-
* Looping the single-row {@link delete} rather than emitting one
|
|
1074
|
-
* `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
|
|
1075
|
-
* statement would be faster and would skip every callback, so a collection
|
|
1076
|
-
* relying on `beforeDelete` to veto or on `afterDelete` to clean up
|
|
1077
|
-
* dependents would behave differently depending on how many rows the caller
|
|
1078
|
-
* happened to delete at once. Same pipeline, one transaction.
|
|
1079
|
-
*/
|
|
1080
|
-
async deleteMany<M extends Record<string, unknown>>({
|
|
1081
|
-
path,
|
|
1082
|
-
ids,
|
|
1083
|
-
collection
|
|
1084
|
-
}: DeleteManyProps<M>): Promise<void> {
|
|
1085
|
-
await this.db.transaction(async (tx) => {
|
|
1086
|
-
const txDriver = new PostgresBackendDriver(
|
|
1087
|
-
tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
|
|
1088
|
-
);
|
|
1089
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
1090
|
-
txDriver.client = this.client;
|
|
1091
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
1092
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
1093
|
-
|
|
1094
|
-
for (let i = 0; i < ids.length; i++) {
|
|
1095
|
-
const id = ids[i];
|
|
1096
|
-
try {
|
|
1097
|
-
const existing = await txDriver.fetchOne({
|
|
1098
|
-
path,
|
|
1099
|
-
id: String(id),
|
|
1100
|
-
collection: collection as CollectionConfig
|
|
1101
|
-
});
|
|
1102
|
-
if (!existing) {
|
|
1103
|
-
throw Object.assign(new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
1104
|
-
statusCode: 404,
|
|
1105
|
-
code: "NOT_FOUND"
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
await txDriver.delete<M>({
|
|
1110
|
-
row: {
|
|
1111
|
-
// The address from the caller, not read back off the
|
|
1112
|
-
// row: a row is only its columns, so `existing.id` is
|
|
1113
|
-
// undefined for any table not keyed on `id`.
|
|
1114
|
-
id: String(id),
|
|
1115
|
-
path,
|
|
1116
|
-
values: existing as Partial<EntityValues<M>>
|
|
1117
|
-
},
|
|
1118
|
-
collection
|
|
1119
|
-
});
|
|
1120
|
-
} catch (error) {
|
|
1121
|
-
throw Object.assign(
|
|
1122
|
-
new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
|
|
1123
|
-
{
|
|
1124
|
-
statusCode: (error as { statusCode?: number })?.statusCode,
|
|
1125
|
-
code: (error as { code?: string })?.code,
|
|
1126
|
-
name: (error as Error)?.name
|
|
1127
|
-
}
|
|
1128
|
-
);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
});
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
async delete<M extends Record<string, unknown>>({
|
|
1135
|
-
row,
|
|
1136
|
-
collection
|
|
1137
|
-
}: DeleteProps<M>): Promise<void> {
|
|
1138
|
-
|
|
1139
|
-
const targetPath = row.path;
|
|
1140
|
-
// The callbacks' `row` is the row: its columns, nothing else. The address
|
|
1141
|
-
// travels beside it as `id`, so merging it in here only ever invented an
|
|
1142
|
-
// `id` field for tables that have no such column.
|
|
1143
|
-
const targetRow: Record<string, unknown> = { ...(row.values ?? {}) };
|
|
1144
|
-
|
|
1145
|
-
// Resolve from backend registry to restore callbacks lost during WebSocket serialization
|
|
1146
|
-
const {
|
|
1147
|
-
collection: resolvedCollection,
|
|
1148
|
-
callbacks,
|
|
1149
|
-
globalCallbacks,
|
|
1150
|
-
propertyCallbacks
|
|
1151
|
-
} = this.resolveCollectionCallbacks(collection, targetPath);
|
|
1152
|
-
|
|
1153
|
-
const contextForCallback = this.buildCallContext();
|
|
1154
|
-
|
|
1155
|
-
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
1156
|
-
let preventDefault = false;
|
|
1157
|
-
// 1. Global callbacks first
|
|
1158
|
-
if (globalCallbacks?.beforeDelete) {
|
|
1159
|
-
const result = await globalCallbacks.beforeDelete({
|
|
1160
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
1161
|
-
path: targetPath,
|
|
1162
|
-
id: row.id,
|
|
1163
|
-
row: targetRow,
|
|
1164
|
-
context: contextForCallback
|
|
1165
|
-
});
|
|
1166
|
-
if (result === false) {
|
|
1167
|
-
preventDefault = true;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
// 2. Collection callbacks second
|
|
1171
|
-
if (callbacks?.beforeDelete) {
|
|
1172
|
-
const result = await callbacks.beforeDelete({
|
|
1173
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
1174
|
-
path: targetPath,
|
|
1175
|
-
id: row.id,
|
|
1176
|
-
row: targetRow,
|
|
1177
|
-
context: contextForCallback
|
|
1178
|
-
});
|
|
1179
|
-
if (result === false) {
|
|
1180
|
-
preventDefault = true;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
// 3. Property callbacks third
|
|
1184
|
-
if (propertyCallbacks?.beforeDelete) {
|
|
1185
|
-
const result = await propertyCallbacks.beforeDelete({
|
|
1186
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
1187
|
-
path: targetPath,
|
|
1188
|
-
id: row.id,
|
|
1189
|
-
row: targetRow,
|
|
1190
|
-
context: contextForCallback
|
|
1191
|
-
});
|
|
1192
|
-
if (result === false) {
|
|
1193
|
-
preventDefault = true;
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
if (preventDefault) {
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
await this.dataService.delete(
|
|
1202
|
-
targetPath,
|
|
1203
|
-
row.id,
|
|
1204
|
-
resolvedCollection?.databaseId
|
|
1205
|
-
);
|
|
1206
|
-
|
|
1207
|
-
if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
|
|
1208
|
-
// 1. Global callbacks first
|
|
1209
|
-
if (globalCallbacks?.afterDelete) {
|
|
1210
|
-
await globalCallbacks.afterDelete({
|
|
1211
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
1212
|
-
path: targetPath,
|
|
1213
|
-
id: row.id,
|
|
1214
|
-
row: targetRow,
|
|
1215
|
-
context: contextForCallback
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
// 2. Collection callbacks second
|
|
1219
|
-
if (callbacks?.afterDelete) {
|
|
1220
|
-
await callbacks.afterDelete({
|
|
1221
|
-
collection: resolvedCollection as CollectionConfig<M>,
|
|
1222
|
-
path: targetPath,
|
|
1223
|
-
id: row.id,
|
|
1224
|
-
row: targetRow,
|
|
1225
|
-
context: contextForCallback
|
|
1226
|
-
});
|
|
1227
|
-
}
|
|
1228
|
-
// 3. Property callbacks third
|
|
1229
|
-
if (propertyCallbacks?.afterDelete) {
|
|
1230
|
-
await propertyCallbacks.afterDelete({
|
|
1231
|
-
collection: resolvedCollection as unknown as CollectionConfig,
|
|
1232
|
-
path: targetPath,
|
|
1233
|
-
id: row.id,
|
|
1234
|
-
row: targetRow,
|
|
1235
|
-
context: contextForCallback
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
// Record delete history (fire-and-forget)
|
|
1241
|
-
if (this.historyService && resolvedCollection?.history) {
|
|
1242
|
-
this.historyService.recordHistory({
|
|
1243
|
-
tableName: targetPath,
|
|
1244
|
-
id: row.id.toString(),
|
|
1245
|
-
action: "delete",
|
|
1246
|
-
values: row.values as Record<string, unknown> ?? {},
|
|
1247
|
-
updatedBy: this.user?.uid
|
|
1248
|
-
});
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Notify real-time subscribers (deferred if inside a transaction)
|
|
1252
|
-
if (this._deferNotifications) {
|
|
1253
|
-
this._pendingNotifications.push({
|
|
1254
|
-
path: targetPath,
|
|
1255
|
-
id: row.id.toString(),
|
|
1256
|
-
row: null,
|
|
1257
|
-
databaseId: resolvedCollection?.databaseId
|
|
1258
|
-
});
|
|
1259
|
-
} else {
|
|
1260
|
-
await this.realtimeService.notifyUpdate(
|
|
1261
|
-
targetPath,
|
|
1262
|
-
row.id.toString(),
|
|
1263
|
-
null,
|
|
1264
|
-
resolvedCollection?.databaseId
|
|
1265
|
-
);
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
async deleteAll(path: string): Promise<void> {
|
|
1271
|
-
await this.dataService.deleteAll(path);
|
|
1272
|
-
// Notify real-time subscribers of bulk change
|
|
1273
|
-
await this.realtimeService.notifyUpdate(path, "*", null);
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
async checkUniqueField(
|
|
1277
|
-
path: string,
|
|
1278
|
-
name: string,
|
|
1279
|
-
value: unknown,
|
|
1280
|
-
id?: string,
|
|
1281
|
-
collection?: CollectionConfig
|
|
1282
|
-
): Promise<boolean> {
|
|
1283
|
-
return this.dataService.checkUniqueField(
|
|
1284
|
-
path,
|
|
1285
|
-
name,
|
|
1286
|
-
value,
|
|
1287
|
-
id,
|
|
1288
|
-
collection?.databaseId
|
|
1289
|
-
);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
async count<M extends Record<string, unknown>>({
|
|
1293
|
-
path,
|
|
1294
|
-
collection,
|
|
1295
|
-
filter,
|
|
1296
|
-
logical,
|
|
1297
|
-
searchString,
|
|
1298
|
-
vectorSearch
|
|
1299
|
-
}: FetchCollectionProps<M>): Promise<number> {
|
|
1300
|
-
return this.dataService.count(
|
|
1301
|
-
path,
|
|
1302
|
-
{
|
|
1303
|
-
filter,
|
|
1304
|
-
// Counted as well as filtered, or `meta.total` describes a
|
|
1305
|
-
// different set of rows from the `data` beside it. The same
|
|
1306
|
-
// held for a `vectorSearch` carrying a `threshold`: it narrows
|
|
1307
|
-
// the fetch, so it has to narrow the count.
|
|
1308
|
-
logical,
|
|
1309
|
-
searchString,
|
|
1310
|
-
vectorSearch
|
|
1311
|
-
}
|
|
1312
|
-
);
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
private getTargetDb(databaseName?: string): DrizzleClient {
|
|
1316
|
-
if (!databaseName || databaseName === this.poolManager?.defaultDatabaseName) {
|
|
1317
|
-
return this.db;
|
|
1318
|
-
}
|
|
1319
|
-
if (!this.poolManager) {
|
|
1320
|
-
throw new Error(
|
|
1321
|
-
"Cross-database execution requires adminConnectionString to be configured in the backend."
|
|
1322
|
-
);
|
|
1323
|
-
}
|
|
1324
|
-
return this.poolManager.getDrizzle(databaseName);
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
/**
|
|
1328
|
-
* Build one statement, binding `$n` placeholders as real parameters.
|
|
1329
|
-
*
|
|
1330
|
-
* Shared by the role-switched path (inside a transaction) and the
|
|
1331
|
-
* unswitched one. They held byte-identical copies of this loop, which is
|
|
1332
|
-
* exactly the shape where a fix lands in one copy and not the other.
|
|
1333
|
-
*/
|
|
1334
|
-
private buildStatement(sqlText: string, params?: unknown[]) {
|
|
1335
|
-
if (!params || params.length === 0) return drizzleSql.raw(sqlText);
|
|
1336
|
-
const parts = sqlText.split(/\$(\d+)/);
|
|
1337
|
-
const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
|
|
1338
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1339
|
-
if (i % 2 === 0) {
|
|
1340
|
-
if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
|
|
1341
|
-
} else {
|
|
1342
|
-
chunks.push(drizzleSql.param(params[Number(parts[i]) - 1]));
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
return drizzleSql.join(chunks, drizzleSql.raw(""));
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
async executeSql(sqlText: string, options?: {
|
|
1349
|
-
database?: string,
|
|
1350
|
-
role?: string,
|
|
1351
|
-
params?: unknown[]
|
|
1352
|
-
}): Promise<Record<string, unknown>[]> {
|
|
1353
|
-
if (!options?.database && !options?.role) {
|
|
1354
|
-
return this.dataService.executeSql(sqlText, options?.params);
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
const targetDb = this.getTargetDb(options?.database);
|
|
1358
|
-
|
|
1359
|
-
try {
|
|
1360
|
-
// Does this actually need a role switch?
|
|
1361
|
-
//
|
|
1362
|
-
// Asking for the role the session already runs as is a no-op, not a
|
|
1363
|
-
// downgrade — the statement really does execute as the requested
|
|
1364
|
-
// role — so it stays allowed even where switching is unavailable.
|
|
1365
|
-
// That is the ordinary Studio path: the role picker defaults to
|
|
1366
|
-
// `current_user`.
|
|
1367
|
-
let needsRoleSwitch = false;
|
|
1368
|
-
if (options?.role) {
|
|
1369
|
-
try {
|
|
1370
|
-
const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
|
|
1371
|
-
const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
|
|
1372
|
-
needsRoleSwitch = !!currentRole && currentRole !== options.role;
|
|
1373
|
-
} catch {
|
|
1374
|
-
// Current role unknown. Assume a switch is needed rather
|
|
1375
|
-
// than assume the session already is the requested role:
|
|
1376
|
-
// attempting and refusing beats guessing in our own favour.
|
|
1377
|
-
needsRoleSwitch = true;
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
if (needsRoleSwitch && options?.role) {
|
|
1382
|
-
if (isRoleSwitchingOptedOut()) {
|
|
1383
|
-
// The one sanctioned way to run this unswitched, and a
|
|
1384
|
-
// decision somebody made rather than a failure: the env var
|
|
1385
|
-
// is documented (README, docs/getting-started/configuration)
|
|
1386
|
-
// as "run SQL Editor queries as the connection owner", for
|
|
1387
|
-
// deployments whose application roles have no database role
|
|
1388
|
-
// behind them. `effectiveSqlRole` reads the same switch, so
|
|
1389
|
-
// the audit log records the role that actually applied.
|
|
1390
|
-
logger.debug(
|
|
1391
|
-
`[PostgresBackendDriver] DISABLE_DB_ROLE_SWITCHING=true — running as the ` +
|
|
1392
|
-
`connection owner rather than "${options.role}".`
|
|
1393
|
-
);
|
|
1394
|
-
} else if (this._roleSwitchingUnavailable) {
|
|
1395
|
-
// Already learned this connection cannot SET ROLE; refuse
|
|
1396
|
-
// without spending the round trip to be told again.
|
|
1397
|
-
throw new RoleSwitchUnavailableError(options.role);
|
|
1398
|
-
} else {
|
|
1399
|
-
const safeRole = options.role.replace(/"/g, "\"\"");
|
|
1400
|
-
try {
|
|
1401
|
-
return await targetDb.transaction(async (tx) => {
|
|
1402
|
-
await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
|
|
1403
|
-
const result = await tx.execute(this.buildStatement(sqlText, options?.params));
|
|
1404
|
-
return result.rows as Record<string, unknown>[];
|
|
1405
|
-
});
|
|
1406
|
-
} catch (roleError: unknown) {
|
|
1407
|
-
if (isRoleSwitchingPermissionError(roleError)) {
|
|
1408
|
-
// SECURITY: do NOT fall through and run this as the
|
|
1409
|
-
// owner.
|
|
1410
|
-
//
|
|
1411
|
-
// The caller asked for a *constrained* execution.
|
|
1412
|
-
// Owner rows are not a degraded answer to that
|
|
1413
|
-
// question, they are a confident wrong one: the only
|
|
1414
|
-
// reason to pass a role is to see what the database
|
|
1415
|
-
// looks like under RLS, and owner output makes a
|
|
1416
|
-
// protected table read as exposed. This used to warn
|
|
1417
|
-
// and continue — and latch, so a single failure
|
|
1418
|
-
// silently unscoped every later call in the process.
|
|
1419
|
-
//
|
|
1420
|
-
// `applyAuthContext` (the user request path) and
|
|
1421
|
-
// `scopeDataDriver` both fail closed. This is the
|
|
1422
|
-
// same question, so it gets the same answer.
|
|
1423
|
-
this._roleSwitchingUnavailable = true;
|
|
1424
|
-
throw new RoleSwitchUnavailableError(options.role, roleError);
|
|
1425
|
-
}
|
|
1426
|
-
throw roleError;
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
const result = await targetDb.execute(this.buildStatement(sqlText, options?.params));
|
|
1432
|
-
return result.rows as Record<string, unknown>[];
|
|
1433
|
-
} catch (error: unknown) {
|
|
1434
|
-
if (error instanceof RoleSwitchUnavailableError) throw error;
|
|
1435
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1436
|
-
// Provide a user-friendly message for connection/auth errors
|
|
1437
|
-
if (msg.includes("pg_hba.conf") || msg.includes("no encryption") || msg.includes("connection refused")) {
|
|
1438
|
-
const dbName = options?.database || "unknown";
|
|
1439
|
-
throw new Error(`Cannot connect to database "${dbName}": the server rejected the connection. This database may require SSL or is not accessible from this host.`);
|
|
1440
|
-
}
|
|
1441
|
-
throw error;
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
async fetchAvailableDatabases(): Promise<string[]> {
|
|
1446
|
-
// Exclude template databases, Cloud SQL internal databases, and the default 'postgres' system db
|
|
1447
|
-
const result = await this.executeSql(
|
|
1448
|
-
`SELECT datname FROM pg_database
|
|
1449
|
-
WHERE datistemplate = false
|
|
1450
|
-
AND datname NOT IN ('postgres', 'cloudsqladmin', '_cloudsqladmin')
|
|
1451
|
-
ORDER BY datname;`
|
|
1452
|
-
);
|
|
1453
|
-
const databases = result.map((r: Record<string, unknown>) => r.datname as string);
|
|
1454
|
-
// Ensure the current connected database is always first in the list
|
|
1455
|
-
const currentDb = this.poolManager?.defaultDatabaseName;
|
|
1456
|
-
if (currentDb && !databases.includes(currentDb)) {
|
|
1457
|
-
databases.unshift(currentDb);
|
|
1458
|
-
} else if (currentDb) {
|
|
1459
|
-
// Move it to the front
|
|
1460
|
-
const idx = databases.indexOf(currentDb);
|
|
1461
|
-
if (idx > 0) {
|
|
1462
|
-
databases.splice(idx, 1);
|
|
1463
|
-
databases.unshift(currentDb);
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
return databases;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
async fetchAvailableRoles(): Promise<string[]> {
|
|
1470
|
-
const result = await this.executeSql(
|
|
1471
|
-
"SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;"
|
|
1472
|
-
);
|
|
1473
|
-
return result.map((r: Record<string, unknown>) => r.rolname as string);
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
/**
|
|
1477
|
-
* Application-level roles actually in use in this project.
|
|
1478
|
-
*
|
|
1479
|
-
* Distinct from {@link fetchAvailableRoles}, which returns native
|
|
1480
|
-
* PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
|
|
1481
|
-
* are the roles the SQL editor can `SET ROLE` to. *These* are the strings
|
|
1482
|
-
* held in the users table's `roles` column, injected per-transaction as
|
|
1483
|
-
* `rebase.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
|
|
1484
|
-
* into a `SecurityRule.roles` field produces a condition no user can ever
|
|
1485
|
-
* satisfy, so the two must not be conflated.
|
|
1486
|
-
*
|
|
1487
|
-
* Roles have no registry table — they were migrated out of
|
|
1488
|
-
* `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
|
|
1489
|
-
* set is derived from what is assigned. A role that is declared in a policy
|
|
1490
|
-
* but held by nobody yet cannot be discovered here; callers that need it
|
|
1491
|
-
* should union in the roles they already know about.
|
|
1492
|
-
*/
|
|
1493
|
-
async fetchApplicationRoles(): Promise<string[]> {
|
|
1494
|
-
// The users table lives in `rebase` for a default (public) setup, but
|
|
1495
|
-
// follows the configured schema otherwise — locate it rather than
|
|
1496
|
-
// assuming. The `roles` ARRAY column is what makes it the auth table.
|
|
1497
|
-
const located = await this.executeSql(`
|
|
1498
|
-
SELECT table_schema, table_name
|
|
1499
|
-
FROM information_schema.columns
|
|
1500
|
-
WHERE column_name = 'roles'
|
|
1501
|
-
AND data_type = 'ARRAY'
|
|
1502
|
-
AND table_name = 'users'
|
|
1503
|
-
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
1504
|
-
ORDER BY (table_schema = 'rebase') DESC, table_schema
|
|
1505
|
-
LIMIT 1;
|
|
1506
|
-
`);
|
|
1507
|
-
if (located.length === 0) return [];
|
|
1508
|
-
|
|
1509
|
-
const schema = located[0].table_schema as string;
|
|
1510
|
-
const table = located[0].table_name as string;
|
|
1511
|
-
// Identifiers come from information_schema, not user input, but they
|
|
1512
|
-
// are still interpolated — quote them so odd-but-legal names survive.
|
|
1513
|
-
const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
|
|
1514
|
-
|
|
1515
|
-
const rows = await this.executeSql(`
|
|
1516
|
-
SELECT DISTINCT unnest(roles) AS role
|
|
1517
|
-
FROM ${qualified}
|
|
1518
|
-
WHERE roles IS NOT NULL
|
|
1519
|
-
ORDER BY role;
|
|
1520
|
-
`);
|
|
1521
|
-
return rows
|
|
1522
|
-
.map((r) => r.role as string)
|
|
1523
|
-
.filter((r): r is string => typeof r === "string" && r.length > 0);
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
async fetchCurrentDatabase(): Promise<string | undefined> {
|
|
1527
|
-
return this.poolManager?.defaultDatabaseName;
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
/**
|
|
1531
|
-
* Fetch public tables that are not yet mapped to a collection.
|
|
1532
|
-
* Excludes internal tables (_rebase_*, _auth_*, auth tables, etc.)
|
|
1533
|
-
* and junction/connection tables used for many-to-many relations.
|
|
1534
|
-
*/
|
|
1535
|
-
async fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]> {
|
|
1536
|
-
const result = await this.executeSql(`
|
|
1537
|
-
SELECT table_name
|
|
1538
|
-
FROM information_schema.tables
|
|
1539
|
-
WHERE table_schema = 'public'
|
|
1540
|
-
AND table_type = 'BASE TABLE'
|
|
1541
|
-
ORDER BY table_name;
|
|
1542
|
-
`);
|
|
1543
|
-
|
|
1544
|
-
const allTables = result
|
|
1545
|
-
.map((r: Record<string, unknown>) => r.table_name as string)
|
|
1546
|
-
.filter((name: string) => classifyTable(name, "public") !== "rebase-internal");
|
|
1547
|
-
|
|
1548
|
-
// Detect junction tables: tables where every column is part of a foreign key.
|
|
1549
|
-
// These are typically many-to-many connection tables and shouldn't be suggested.
|
|
1550
|
-
let junctionTables = new Set<string>();
|
|
1551
|
-
try {
|
|
1552
|
-
junctionTables = await detectJunctionTables(this.executeSql.bind(this));
|
|
1553
|
-
} catch (e) {
|
|
1554
|
-
logger.warn("Could not detect junction tables", { error: e });
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
const filteredTables = allTables.filter(name => !junctionTables.has(name));
|
|
1558
|
-
|
|
1559
|
-
if (!mappedPaths || mappedPaths.length === 0) return filteredTables;
|
|
1560
|
-
|
|
1561
|
-
const mappedSet = new Set(mappedPaths.map(p => p.toLowerCase()));
|
|
1562
|
-
return filteredTables.filter((name: string) => !mappedSet.has(name.toLowerCase()));
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
/**
|
|
1566
|
-
* Fetch metadata for a given table from information_schema (columns, policies, constraints).
|
|
1567
|
-
*/
|
|
1568
|
-
async fetchTableMetadata(tableName: string): Promise<TableMetadata> {
|
|
1569
|
-
// Sanitize table name as defense-in-depth (parameterized below)
|
|
1570
|
-
const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
1571
|
-
|
|
1572
|
-
// 1. Fetch Columns
|
|
1573
|
-
const result = await this.db.execute(drizzleSql`
|
|
1574
|
-
SELECT column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length
|
|
1575
|
-
FROM information_schema.columns
|
|
1576
|
-
WHERE table_schema = 'public'
|
|
1577
|
-
AND table_name = ${safeName}
|
|
1578
|
-
ORDER BY ordinal_position
|
|
1579
|
-
`);
|
|
1580
|
-
const columns = result.rows as Record<string, unknown>[];
|
|
1581
|
-
|
|
1582
|
-
// Also fetch enum values for any USER-DEFINED columns
|
|
1583
|
-
const enumColumns = columns.filter((c) => c.data_type === "USER-DEFINED");
|
|
1584
|
-
if (enumColumns.length > 0) {
|
|
1585
|
-
for (const col of enumColumns) {
|
|
1586
|
-
try {
|
|
1587
|
-
const enumResult = await this.db.execute(drizzleSql`
|
|
1588
|
-
SELECT e.enumlabel
|
|
1589
|
-
FROM pg_type t
|
|
1590
|
-
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
1591
|
-
WHERE t.typname = ${col.udt_name as string}
|
|
1592
|
-
ORDER BY e.enumsortorder
|
|
1593
|
-
`);
|
|
1594
|
-
col.enum_values = (enumResult.rows as Record<string, unknown>[]).map(e => e.enumlabel);
|
|
1595
|
-
} catch {
|
|
1596
|
-
col.enum_values = [];
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
// SAFETY: Raw SQL result rows are typed as QueryResultRow[]; the query shape matches TableColumnInfo
|
|
1601
|
-
const typedColumns = columns as unknown as TableColumnInfo[];
|
|
1602
|
-
|
|
1603
|
-
// 2. Fetch Foreign Keys
|
|
1604
|
-
const fkResult = await this.db.execute(drizzleSql`
|
|
1605
|
-
SELECT
|
|
1606
|
-
kcu.column_name as column_name,
|
|
1607
|
-
ccu.table_name AS foreign_table_name,
|
|
1608
|
-
ccu.column_name AS foreign_column_name
|
|
1609
|
-
FROM
|
|
1610
|
-
information_schema.table_constraints AS tc
|
|
1611
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
1612
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
1613
|
-
AND tc.table_schema = kcu.table_schema
|
|
1614
|
-
JOIN information_schema.constraint_column_usage AS ccu
|
|
1615
|
-
ON ccu.constraint_name = tc.constraint_name
|
|
1616
|
-
AND ccu.table_schema = tc.table_schema
|
|
1617
|
-
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ${safeName};
|
|
1618
|
-
`);
|
|
1619
|
-
// SAFETY: Raw SQL result rows match TableForeignKeyInfo shape from the SELECT aliases
|
|
1620
|
-
const foreignKeys = fkResult.rows as TableForeignKeyInfo[];
|
|
1621
|
-
|
|
1622
|
-
// 3. Fetch Junction Tables (Many-to-Many)
|
|
1623
|
-
// A simple junction table is one that has foreign keys to our table and other tables
|
|
1624
|
-
const junctionsResult = await this.db.execute(drizzleSql`
|
|
1625
|
-
SELECT
|
|
1626
|
-
tc1.table_name as junction_table_name,
|
|
1627
|
-
kcu1.column_name as source_column_name,
|
|
1628
|
-
ccu2.table_name as target_table_name,
|
|
1629
|
-
kcu2.column_name as target_column_name
|
|
1630
|
-
FROM information_schema.table_constraints tc1
|
|
1631
|
-
JOIN information_schema.key_column_usage kcu1 ON tc1.constraint_name = kcu1.constraint_name
|
|
1632
|
-
JOIN information_schema.constraint_column_usage ccu1 ON ccu1.constraint_name = tc1.constraint_name
|
|
1633
|
-
JOIN information_schema.table_constraints tc2 ON tc1.table_name = tc2.table_name AND tc2.constraint_type = 'FOREIGN KEY'
|
|
1634
|
-
JOIN information_schema.key_column_usage kcu2 ON tc2.constraint_name = kcu2.constraint_name
|
|
1635
|
-
JOIN information_schema.constraint_column_usage ccu2 ON ccu2.constraint_name = tc2.constraint_name
|
|
1636
|
-
WHERE tc1.constraint_type = 'FOREIGN KEY'
|
|
1637
|
-
AND ccu1.table_name = ${safeName}
|
|
1638
|
-
AND ccu2.table_name != ${safeName};
|
|
1639
|
-
`);
|
|
1640
|
-
// SAFETY: Raw SQL result rows match TableJunctionInfo shape from the SELECT aliases
|
|
1641
|
-
const junctions = junctionsResult.rows as TableJunctionInfo[];
|
|
1642
|
-
|
|
1643
|
-
// 4. Fetch RLS Policies
|
|
1644
|
-
const policiesResult = await this.db.execute(drizzleSql`
|
|
1645
|
-
SELECT
|
|
1646
|
-
polname as policy_name,
|
|
1647
|
-
polcmd as cmd,
|
|
1648
|
-
polroles::regrole[]::text[] as roles,
|
|
1649
|
-
pg_get_expr(polqual, polrelid) as qual,
|
|
1650
|
-
pg_get_expr(polwithcheck, polrelid) as with_check
|
|
1651
|
-
FROM pg_policy
|
|
1652
|
-
WHERE polrelid = (SELECT oid FROM pg_class WHERE relname = ${safeName} AND relnamespace = 'public'::regnamespace);
|
|
1653
|
-
`);
|
|
1654
|
-
// SAFETY: Raw SQL result rows match TablePolicyInfo shape from the SELECT aliases
|
|
1655
|
-
const policies = policiesResult.rows as TablePolicyInfo[];
|
|
1656
|
-
|
|
1657
|
-
return {
|
|
1658
|
-
columns: typedColumns,
|
|
1659
|
-
foreignKeys,
|
|
1660
|
-
junctions,
|
|
1661
|
-
policies
|
|
1662
|
-
};
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
private generateSubscriptionId(): string {
|
|
1666
|
-
return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
/**
|
|
1670
|
-
* Create a new delegate instance with authenticated context.
|
|
1671
|
-
* Starts a transaction and sets the current_user_id and current_user_roles
|
|
1672
|
-
* configuration parameters for PostgreSQL Row Level Security.
|
|
1673
|
-
*/
|
|
1674
|
-
async withAuth(user: User): Promise<DataDriver> {
|
|
1675
|
-
return new AuthenticatedPostgresBackendDriver(this, user);
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
export class AuthenticatedPostgresBackendDriver implements DataDriver {
|
|
1680
|
-
key = "postgres";
|
|
1681
|
-
initialised = true;
|
|
1682
|
-
|
|
1683
|
-
public user: User;
|
|
1684
|
-
public data: RebaseSdkData;
|
|
1685
|
-
|
|
1686
|
-
constructor(
|
|
1687
|
-
public delegate: PostgresBackendDriver,
|
|
1688
|
-
user: User
|
|
1689
|
-
) {
|
|
1690
|
-
this.user = user;
|
|
1691
|
-
this.data = buildSdkData(this);
|
|
1692
|
-
|
|
1693
|
-
// Delegate admin ops to the base driver (no RLS wrapping for admin)
|
|
1694
|
-
this.admin = delegate.admin;
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
/**
|
|
1698
|
-
* Typed admin capabilities — delegates to the base driver.
|
|
1699
|
-
*/
|
|
1700
|
-
admin: DatabaseAdmin;
|
|
1701
|
-
|
|
1702
|
-
get restFetchService(): RestFetchService {
|
|
1703
|
-
return {
|
|
1704
|
-
// The base driver's restFetchService already applies the afterRead
|
|
1705
|
-
// pipeline, so we only wrap it in the authenticated transaction here.
|
|
1706
|
-
fetchCollectionForRest: async (collectionPath, options, include) => {
|
|
1707
|
-
return this.withTransaction(async (delegate) => {
|
|
1708
|
-
return delegate.restFetchService.fetchCollectionForRest(collectionPath, options, include);
|
|
1709
|
-
}, { accessMode: "read only" });
|
|
1710
|
-
},
|
|
1711
|
-
fetchOneForRest: async (collectionPath, id, include, databaseId) => {
|
|
1712
|
-
return this.withTransaction(async (delegate) => {
|
|
1713
|
-
return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId);
|
|
1714
|
-
}, { accessMode: "read only" });
|
|
1715
|
-
}
|
|
1716
|
-
};
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
private async withTransaction<T>(
|
|
1720
|
-
operation: (delegate: PostgresBackendDriver) => Promise<T>,
|
|
1721
|
-
options?: {
|
|
1722
|
-
accessMode?: "read only" | "read write";
|
|
1723
|
-
isolationLevel?: "read uncommitted" | "read committed" | "repeatable read" | "serializable"
|
|
1724
|
-
}
|
|
1725
|
-
): Promise<T> {
|
|
1726
|
-
const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
|
|
1727
|
-
|
|
1728
|
-
const result = await this.delegate.db.transaction(async (tx) => {
|
|
1729
|
-
let uid = this.user?.uid;
|
|
1730
|
-
if (!uid) {
|
|
1731
|
-
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
1732
|
-
uid = "anonymous";
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
const userRoles = this.user?.roles ?? [];
|
|
1736
|
-
if (!this.user?.roles) {
|
|
1737
|
-
logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
// Set the RLS GUCs and downgrade to the restricted user role so RLS
|
|
1741
|
-
// binds every statement in this transaction — reads AND writes.
|
|
1742
|
-
// This is user context: the collection's securityRules are the
|
|
1743
|
-
// authorization model. The BASE driver never reaches here, so it
|
|
1744
|
-
// stays on the owner connection and bypasses RLS — but note that
|
|
1745
|
-
// `rebase.dataAsAdmin` is NOT the base driver: `init.ts` scopes it
|
|
1746
|
-
// with `withAuth(SERVICE_IDENTITY)`, so it arrives here like any
|
|
1747
|
-
// other user, with uid 'service' and the admin role, and its
|
|
1748
|
-
// statements are RLS-evaluated. The comment used to list it as a
|
|
1749
|
-
// bypass and five docblocks followed. The GUCs are transaction-local and
|
|
1750
|
-
// remain readable after the role switch, so `rebase.uid()` /
|
|
1751
|
-
// `rebase.roles()` in policies still resolve.
|
|
1752
|
-
//
|
|
1753
|
-
// Fails closed: if the switch cannot be performed, the transaction
|
|
1754
|
-
// aborts rather than falling back to an RLS-bypassing connection.
|
|
1755
|
-
await applyAuthContext(tx, { uid, roles: userRoles }, this.delegate.rlsUserRole);
|
|
1756
|
-
|
|
1757
|
-
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
1758
|
-
const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
|
|
1759
|
-
|
|
1760
|
-
txDelegate.dataService = txEntityService;
|
|
1761
|
-
txDelegate._deferNotifications = true;
|
|
1762
|
-
txDelegate._pendingNotifications = pendingNotifications;
|
|
1763
|
-
txDelegate.client = this.delegate.client;
|
|
1764
|
-
|
|
1765
|
-
return await operation(txDelegate);
|
|
1766
|
-
}, options);
|
|
1767
|
-
|
|
1768
|
-
for (const notification of pendingNotifications) {
|
|
1769
|
-
try {
|
|
1770
|
-
await this.delegate.realtimeService.notifyUpdate(
|
|
1771
|
-
notification.path,
|
|
1772
|
-
notification.id,
|
|
1773
|
-
notification.row,
|
|
1774
|
-
notification.databaseId
|
|
1775
|
-
);
|
|
1776
|
-
} catch (e) {
|
|
1777
|
-
logger.error("[DataDriver] Error flushing deferred notification", { error: e });
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
return result;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
|
|
1785
|
-
return this.withTransaction((delegate) => delegate.fetchCollection(props), { accessMode: "read only" });
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
/**
|
|
1789
|
-
* Injects the authenticated user's context into the most recently
|
|
1790
|
-
* registered realtime subscription so RLS-aware polling can apply.
|
|
1791
|
-
*/
|
|
1792
|
-
private injectAuthContext(unsubscribe: () => void): () => void {
|
|
1793
|
-
const authContext = {
|
|
1794
|
-
uid: this.user?.uid || "anonymous",
|
|
1795
|
-
roles: this.user?.roles ?? []
|
|
1796
|
-
};
|
|
1797
|
-
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
1798
|
-
const lastEntry = entries[entries.length - 1];
|
|
1799
|
-
const lastSub = lastEntry?.[1] as Record<string, unknown> | undefined;
|
|
1800
|
-
if (lastSub && lastSub.clientId === "driver") {
|
|
1801
|
-
lastSub.authContext = authContext;
|
|
1802
|
-
}
|
|
1803
|
-
return unsubscribe;
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
listenCollection<M extends Record<string, unknown>>(props: ListenCollectionProps<M>): () => void {
|
|
1807
|
-
return this.injectAuthContext(this.delegate.listenCollection(props));
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
async fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
|
|
1811
|
-
return this.withTransaction((delegate) => delegate.fetchOne(props), { accessMode: "read only" });
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
listenOne<M extends Record<string, unknown>>(props: ListenOneProps<M>): () => void {
|
|
1815
|
-
return this.injectAuthContext(this.delegate.listenOne(props));
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
1819
|
-
return this.withTransaction((delegate) => delegate.save(props));
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
/**
|
|
1823
|
-
* One transaction for the whole batch, rather than one per row.
|
|
1824
|
-
*
|
|
1825
|
-
* This is the point of the method: `save` opens a transaction per call, so
|
|
1826
|
-
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
1827
|
-
* round trips). Here the RLS context is established once and every row lands
|
|
1828
|
-
* or none does. Realtime notifications are already deferred to commit by
|
|
1829
|
-
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
1830
|
-
*/
|
|
1831
|
-
async saveMany<M extends Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
1832
|
-
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
|
|
1836
|
-
return this.withTransaction((delegate) => delegate.delete(props));
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
async deleteAll(path: string): Promise<void> {
|
|
1840
|
-
return this.withTransaction((delegate) => delegate.deleteAll(path));
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
async checkUniqueField(
|
|
1844
|
-
path: string,
|
|
1845
|
-
name: string,
|
|
1846
|
-
value: unknown,
|
|
1847
|
-
id?: string,
|
|
1848
|
-
collection?: CollectionConfig
|
|
1849
|
-
): Promise<boolean> {
|
|
1850
|
-
return this.withTransaction((delegate) => delegate.checkUniqueField(path, name, value, id, collection), { accessMode: "read only" });
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
|
|
1854
|
-
return this.withTransaction((delegate) => delegate.count(props), { accessMode: "read only" });
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
}
|