@rebasepro/server-postgres 0.17.3 → 0.18.1

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.
Files changed (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. 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
- }