@rebasepro/server-postgresql 0.0.1-canary.000dc36

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 (198) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +58 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +22 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +11289 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +11297 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +106 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +192 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +40 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
  43. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  45. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  46. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  47. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
  48. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  49. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  51. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
  52. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  53. package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
  54. package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
  55. package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
  56. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  57. package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
  58. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  59. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  60. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  61. package/dist/types/src/controllers/auth.d.ts +119 -0
  62. package/dist/types/src/controllers/client.d.ts +170 -0
  63. package/dist/types/src/controllers/collection_registry.d.ts +46 -0
  64. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  65. package/dist/types/src/controllers/data.d.ts +168 -0
  66. package/dist/types/src/controllers/data_driver.d.ts +195 -0
  67. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  68. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  69. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  70. package/dist/types/src/controllers/email.d.ts +34 -0
  71. package/dist/types/src/controllers/index.d.ts +18 -0
  72. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  73. package/dist/types/src/controllers/navigation.d.ts +213 -0
  74. package/dist/types/src/controllers/registry.d.ts +54 -0
  75. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  76. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  77. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  78. package/dist/types/src/controllers/storage.d.ts +171 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/rebase_context.d.ts +105 -0
  81. package/dist/types/src/types/backend.d.ts +536 -0
  82. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  83. package/dist/types/src/types/builders.d.ts +15 -0
  84. package/dist/types/src/types/chips.d.ts +5 -0
  85. package/dist/types/src/types/collections.d.ts +857 -0
  86. package/dist/types/src/types/cron.d.ts +102 -0
  87. package/dist/types/src/types/data_source.d.ts +64 -0
  88. package/dist/types/src/types/entities.d.ts +145 -0
  89. package/dist/types/src/types/entity_actions.d.ts +98 -0
  90. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  91. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  92. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  93. package/dist/types/src/types/entity_views.d.ts +59 -0
  94. package/dist/types/src/types/export_import.d.ts +21 -0
  95. package/dist/types/src/types/formex.d.ts +40 -0
  96. package/dist/types/src/types/index.d.ts +25 -0
  97. package/dist/types/src/types/locales.d.ts +4 -0
  98. package/dist/types/src/types/modify_collections.d.ts +5 -0
  99. package/dist/types/src/types/plugins.d.ts +282 -0
  100. package/dist/types/src/types/properties.d.ts +1148 -0
  101. package/dist/types/src/types/property_config.d.ts +70 -0
  102. package/dist/types/src/types/relations.d.ts +336 -0
  103. package/dist/types/src/types/slots.d.ts +262 -0
  104. package/dist/types/src/types/translations.d.ts +874 -0
  105. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  106. package/dist/types/src/types/websockets.d.ts +78 -0
  107. package/dist/types/src/users/index.d.ts +2 -0
  108. package/dist/types/src/users/roles.d.ts +22 -0
  109. package/dist/types/src/users/user.d.ts +46 -0
  110. package/drizzle-test/0000_woozy_junta.sql +6 -0
  111. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  112. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  113. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  114. package/drizzle-test/meta/0000_snapshot.json +47 -0
  115. package/drizzle-test/meta/0001_snapshot.json +48 -0
  116. package/drizzle-test/meta/0002_snapshot.json +38 -0
  117. package/drizzle-test/meta/0003_snapshot.json +48 -0
  118. package/drizzle-test/meta/_journal.json +34 -0
  119. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  120. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  121. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  122. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  123. package/drizzle-test-out/meta/_journal.json +20 -0
  124. package/drizzle.test.config.ts +10 -0
  125. package/jest-all.log +3128 -0
  126. package/jest.log +49 -0
  127. package/package.json +93 -0
  128. package/scratch.ts +41 -0
  129. package/src/PostgresBackendDriver.ts +1017 -0
  130. package/src/PostgresBootstrapper.ts +231 -0
  131. package/src/auth/ensure-tables.ts +381 -0
  132. package/src/auth/services.ts +799 -0
  133. package/src/cli.ts +648 -0
  134. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  135. package/src/connection.ts +84 -0
  136. package/src/data-transformer.ts +608 -0
  137. package/src/databasePoolManager.ts +85 -0
  138. package/src/history/HistoryService.ts +248 -0
  139. package/src/history/ensure-history-table.ts +45 -0
  140. package/src/index.ts +13 -0
  141. package/src/interfaces.ts +60 -0
  142. package/src/schema/auth-schema.ts +169 -0
  143. package/src/schema/doctor-cli.ts +47 -0
  144. package/src/schema/doctor.ts +595 -0
  145. package/src/schema/generate-drizzle-schema-logic.ts +747 -0
  146. package/src/schema/generate-drizzle-schema.ts +151 -0
  147. package/src/schema/introspect-db-logic.ts +592 -0
  148. package/src/schema/introspect-db.ts +211 -0
  149. package/src/schema/test-schema.ts +11 -0
  150. package/src/services/BranchService.ts +237 -0
  151. package/src/services/EntityFetchService.ts +1576 -0
  152. package/src/services/EntityPersistService.ts +355 -0
  153. package/src/services/RelationService.ts +1274 -0
  154. package/src/services/entity-helpers.ts +147 -0
  155. package/src/services/entityService.ts +211 -0
  156. package/src/services/index.ts +13 -0
  157. package/src/services/realtimeService.ts +1034 -0
  158. package/src/utils/drizzle-conditions.ts +1000 -0
  159. package/src/websocket.ts +518 -0
  160. package/test/auth-services.test.ts +661 -0
  161. package/test/batch-many-to-many-regression.test.ts +573 -0
  162. package/test/branchService.test.ts +367 -0
  163. package/test/data-transformer-hardening.test.ts +417 -0
  164. package/test/data-transformer.test.ts +175 -0
  165. package/test/doctor.test.ts +182 -0
  166. package/test/drizzle-conditions.test.ts +895 -0
  167. package/test/entityService.errors.test.ts +367 -0
  168. package/test/entityService.relations.test.ts +1008 -0
  169. package/test/entityService.subcollection-search.test.ts +566 -0
  170. package/test/entityService.test.ts +1035 -0
  171. package/test/generate-drizzle-schema.test.ts +1035 -0
  172. package/test/historyService.test.ts +141 -0
  173. package/test/introspect-db-generation.test.ts +436 -0
  174. package/test/introspect-db-utils.test.ts +392 -0
  175. package/test/n-plus-one-regression.test.ts +314 -0
  176. package/test/postgresDataDriver.test.ts +648 -0
  177. package/test/realtimeService.test.ts +307 -0
  178. package/test/relation-pipeline-gaps.test.ts +637 -0
  179. package/test/relations.test.ts +1115 -0
  180. package/test/unmapped-tables-safety.test.ts +345 -0
  181. package/test-drizzle-bug.ts +18 -0
  182. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  183. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  184. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  185. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  186. package/test-drizzle-out/meta/_journal.json +20 -0
  187. package/test-drizzle-prompt.sh +2 -0
  188. package/test-policy-prompt.sh +3 -0
  189. package/test-programmatic.ts +30 -0
  190. package/test-programmatic2.ts +59 -0
  191. package/test-schema-no-policies.ts +12 -0
  192. package/test_drizzle_mock.js +3 -0
  193. package/test_find_changed.mjs +32 -0
  194. package/test_hash.js +14 -0
  195. package/test_output.txt +3145 -0
  196. package/tsconfig.json +49 -0
  197. package/tsconfig.prod.json +20 -0
  198. package/vite.config.ts +82 -0
@@ -0,0 +1,1017 @@
1
+ // import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { EntityService } from "./services/entityService";
3
+ import { BranchService } from "./services/BranchService";
4
+ import { RealtimeService } from "./services/realtimeService";
5
+ import { DatabasePoolManager } from "./databasePoolManager";
6
+ import { DrizzleClient } from "./interfaces";
7
+ import { User } from "@rebasepro/types";
8
+ import { sql as drizzleSql } from "drizzle-orm";
9
+ import { buildPropertyCallbacks } from "@rebasepro/common";
10
+ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
11
+ import {
12
+ DataDriver,
13
+ DeleteEntityProps,
14
+ Entity,
15
+ EntityCollection,
16
+ FetchCollectionProps,
17
+ FetchEntityProps,
18
+ ListenCollectionProps,
19
+ ListenEntityProps,
20
+ RebaseCallContext,
21
+ SaveEntityProps,
22
+ RebaseData,
23
+ TableMetadata,
24
+ TableColumnInfo,
25
+ TableForeignKeyInfo,
26
+ TableJunctionInfo,
27
+ TablePolicyInfo,
28
+ SQLAdmin,
29
+ SchemaAdmin,
30
+ DatabaseAdmin
31
+ } from "@rebasepro/types";
32
+ import { buildRebaseData } from "@rebasepro/common";
33
+ // @ts-ignore
34
+ import { HistoryService } from "./history/HistoryService";
35
+ import { mergeDeep } from "@rebasepro/utils";
36
+
37
+ export class PostgresBackendDriver implements DataDriver {
38
+ key = "postgres";
39
+ initialised = true;
40
+
41
+ public entityService: EntityService;
42
+ public realtimeService: RealtimeService;
43
+ public historyService?: HistoryService;
44
+ public branchService?: BranchService;
45
+ public user?: User;
46
+ public data: RebaseData;
47
+
48
+ /**
49
+ * When true, realtime notifications are deferred until after the
50
+ * wrapping transaction commits. Set by `withAuth` → `withTransaction`.
51
+ */
52
+ _deferNotifications = false;
53
+ _pendingNotifications: Array<{
54
+ path: string;
55
+ entityId: string;
56
+ entity: Entity | null;
57
+ databaseId?: string;
58
+ }> = [];
59
+
60
+ constructor(
61
+ public db: DrizzleClient,
62
+ realtimeService: RealtimeService,
63
+ public readonly registry: PostgresCollectionRegistry,
64
+ user?: User,
65
+ public poolManager?: DatabasePoolManager,
66
+ historyService?: HistoryService
67
+ ) {
68
+ this.entityService = new EntityService(db, registry);
69
+ this.realtimeService = realtimeService;
70
+ this.historyService = historyService;
71
+ this.user = user;
72
+ this.data = buildRebaseData(this);
73
+
74
+ // Initialize BranchService when adminConnectionString is configured
75
+ if (poolManager) {
76
+ this.branchService = new BranchService(db, poolManager);
77
+ }
78
+
79
+ }
80
+
81
+ /**
82
+ * Typed admin capabilities (SQLAdmin + SchemaAdmin + BranchAdmin).
83
+ * Implemented as a getter so method references are resolved at call-time,
84
+ * allowing test spies applied after construction to take effect.
85
+ */
86
+ get admin(): DatabaseAdmin {
87
+ return {
88
+ executeSql: (...args: Parameters<NonNullable<DatabaseAdmin["executeSql"]>>) => this.executeSql(...args),
89
+ fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
90
+ fetchAvailableRoles: () => this.fetchAvailableRoles(),
91
+ fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
92
+ fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
93
+ fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
94
+ // Branch operations (only available when poolManager is configured)
95
+ ...(this.branchService ? {
96
+ createBranch: this.branchService.createBranch.bind(this.branchService),
97
+ deleteBranch: this.branchService.deleteBranch.bind(this.branchService),
98
+ listBranches: this.branchService.listBranches.bind(this.branchService),
99
+ getBranchInfo: this.branchService.getBranchInfo.bind(this.branchService)
100
+ } : {})
101
+ };
102
+ }
103
+
104
+ /**
105
+ * REST-optimised fetch service (include-aware eager-loading).
106
+ * Delegates to the underlying EntityFetchService which already
107
+ * implements the matching method signatures.
108
+ */
109
+ get restFetchService() {
110
+ return this.entityService.getFetchService();
111
+ }
112
+
113
+
114
+ private resolveCollectionCallbacks<M extends Record<string, unknown>>(collection: EntityCollection<M> | undefined, path: string) {
115
+ if (!collection && !path) return { collection: undefined,
116
+ callbacks: undefined,
117
+ propertyCallbacks: undefined };
118
+ const registryCollection = this.registry.getCollectionByPath(path);
119
+ const resolvedCollection = registryCollection
120
+ ? { ...collection,
121
+ ...registryCollection } as EntityCollection<M>
122
+ : collection as EntityCollection<M>;
123
+
124
+ const callbacks = resolvedCollection?.callbacks;
125
+ const properties = resolvedCollection?.properties;
126
+ let propertyCallbacks;
127
+ if (properties) {
128
+ propertyCallbacks = buildPropertyCallbacks(properties);
129
+ }
130
+ return {
131
+ collection: resolvedCollection,
132
+ callbacks,
133
+ propertyCallbacks
134
+ };
135
+ }
136
+
137
+ async fetchCollection<M extends Record<string, unknown>>({
138
+ path,
139
+ collection,
140
+ filter,
141
+ limit,
142
+ offset,
143
+ startAfter,
144
+ orderBy,
145
+ searchString,
146
+ order
147
+ }: FetchCollectionProps<M>): Promise<Entity<M>[]> {
148
+
149
+ const entities = await this.entityService.fetchCollection<M>(path, {
150
+ filter,
151
+ orderBy,
152
+ order,
153
+ limit,
154
+ offset,
155
+ startAfter: startAfter as Record<string, unknown> | undefined,
156
+ databaseId: collection?.databaseId,
157
+ searchString
158
+ });
159
+
160
+ const { collection: resolvedCollection, callbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
161
+
162
+ if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
163
+ const contextForCallback = {
164
+ user: this.user,
165
+ driver: this,
166
+ data: this.data
167
+ } as unknown as RebaseCallContext; // Backend context
168
+ return Promise.all(entities.map(async (entity) => {
169
+ let fetched = entity;
170
+ if (callbacks?.afterRead) {
171
+ fetched = await callbacks.afterRead({
172
+ collection: resolvedCollection as EntityCollection<M>,
173
+ path,
174
+ entity: fetched,
175
+ context: contextForCallback
176
+ }) ?? fetched;
177
+ }
178
+ if (propertyCallbacks?.afterRead) {
179
+ fetched = await propertyCallbacks.afterRead({
180
+ collection: resolvedCollection as EntityCollection<M>,
181
+ path,
182
+ entity: fetched,
183
+ context: contextForCallback
184
+ }) as Entity<M> ?? fetched;
185
+ }
186
+ return fetched;
187
+ }));
188
+ }
189
+
190
+ return entities;
191
+ }
192
+
193
+ listenCollection<M extends Record<string, unknown>>({
194
+ path,
195
+ collection,
196
+ filter,
197
+ limit,
198
+ offset,
199
+ startAfter,
200
+ orderBy,
201
+ searchString,
202
+ order,
203
+ onUpdate,
204
+ onError
205
+ }: ListenCollectionProps<M>): () => void {
206
+
207
+ const subscriptionId = this.generateSubscriptionId();
208
+
209
+ // Type-adapter wrapper: RealtimeService expects a union callback signature
210
+ const callbackWrapper = (entities: Entity<M>[]) => {
211
+ onUpdate(entities);
212
+ };
213
+
214
+ // Store the subscription in RealtimeService properly using the new public method
215
+ this.realtimeService.registerDataDriverSubscription(subscriptionId, {
216
+ clientId: "driver",
217
+ type: "collection" as const,
218
+ path,
219
+ collectionRequest: {
220
+ filter,
221
+ orderBy,
222
+ order,
223
+ limit,
224
+ offset,
225
+ startAfter: startAfter as Record<string, unknown> | undefined,
226
+ databaseId: collection?.databaseId,
227
+ searchString
228
+ }
229
+ });
230
+
231
+ // Store the callback for this subscription
232
+ this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper as (data: Entity | Entity[] | null) => void);
233
+
234
+ // Send initial data immediately
235
+ this.fetchCollection({
236
+ path: path,
237
+ collection,
238
+ filter,
239
+ limit,
240
+ offset,
241
+ startAfter,
242
+ orderBy,
243
+ searchString,
244
+ order
245
+ }).then(entities => {
246
+ callbackWrapper(entities);
247
+ }).catch(error => {
248
+ if (onError) onError(error);
249
+ });
250
+
251
+ return () => {
252
+ this.realtimeService.removeSubscriptionCallback(subscriptionId);
253
+ this.realtimeService.subscriptions.delete(subscriptionId);
254
+ };
255
+ }
256
+
257
+ async fetchEntity<M extends Record<string, unknown>>({
258
+ path,
259
+ entityId,
260
+ databaseId,
261
+ collection
262
+ }: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
263
+ let entity = await this.entityService.fetchEntity<M>(
264
+ path,
265
+ entityId,
266
+ databaseId || collection?.databaseId
267
+ );
268
+
269
+ const { collection: resolvedCollection, callbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
270
+
271
+ if (entity && (callbacks?.afterRead || propertyCallbacks?.afterRead)) {
272
+ const contextForCallback = {
273
+ user: this.user,
274
+ driver: this,
275
+ data: this.data
276
+ } as unknown as RebaseCallContext; // Backend context
277
+ if (callbacks?.afterRead) {
278
+ entity = await callbacks.afterRead({
279
+ collection: resolvedCollection as EntityCollection<M>,
280
+ path,
281
+ entity,
282
+ context: contextForCallback
283
+ }) ?? entity;
284
+ }
285
+ if (propertyCallbacks?.afterRead) {
286
+ entity = await propertyCallbacks.afterRead({
287
+ collection: resolvedCollection as EntityCollection<M>,
288
+ path,
289
+ entity,
290
+ context: contextForCallback
291
+ }) as Entity<M> ?? entity;
292
+ }
293
+ }
294
+
295
+ return entity;
296
+ }
297
+
298
+ listenEntity<M extends Record<string, unknown>>({
299
+ path,
300
+ entityId,
301
+ collection,
302
+ onUpdate,
303
+ onError
304
+ }: ListenEntityProps<M>): () => void {
305
+
306
+ const subscriptionId = this.generateSubscriptionId();
307
+ const callbackWrapper = (entity: Entity<M> | null) => {
308
+ if (entity)
309
+ onUpdate(entity);
310
+ };
311
+
312
+ // Register the subscription with the RealtimeService
313
+ this.realtimeService.registerDataDriverSubscription(subscriptionId, {
314
+ clientId: "driver",
315
+ type: "entity" as const,
316
+ path,
317
+ entityId
318
+ });
319
+
320
+ // Store the callback for this subscription
321
+ this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper as (data: Entity | Entity[] | null) => void);
322
+
323
+ // Fetch initial data
324
+ this.fetchEntity({
325
+ path,
326
+ entityId,
327
+ collection
328
+ })
329
+ .then(entity => {
330
+ if (entity) onUpdate(entity);
331
+ })
332
+ .catch(error => {
333
+ if (onError) onError(error as Error);
334
+ });
335
+
336
+ // Return the unsubscribe function
337
+ return () => {
338
+ this.realtimeService.removeSubscriptionCallback(subscriptionId);
339
+ this.realtimeService.subscriptions.delete(subscriptionId);
340
+ };
341
+ }
342
+
343
+ async saveEntity<M extends Record<string, unknown>>({
344
+ path,
345
+ entityId,
346
+ values,
347
+ collection,
348
+ status
349
+ }: SaveEntityProps<M>): Promise<Entity<M>> {
350
+
351
+ const { collection: resolvedCollection, callbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
352
+
353
+ let updatedValues = values;
354
+ const contextForCallback = {
355
+ user: this.user,
356
+ driver: this,
357
+ data: this.data
358
+ } as unknown as RebaseCallContext;
359
+
360
+ // Fetch previous values for callbacks AND history recording
361
+ let previousValuesForHistory: Partial<Entity<M>["values"]> | undefined;
362
+ if (status === "existing" && entityId) {
363
+ const existing = await this.entityService.fetchEntity<M>(path, entityId, resolvedCollection?.databaseId);
364
+ if (existing) {
365
+ previousValuesForHistory = existing.values as Partial<Entity<M>["values"]>;
366
+ }
367
+ }
368
+
369
+ if (callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
370
+ if (callbacks?.beforeSave) {
371
+ const result = await callbacks.beforeSave({
372
+ collection: resolvedCollection as EntityCollection<M>,
373
+ path,
374
+ entityId,
375
+ values: updatedValues,
376
+ previousValues: previousValuesForHistory,
377
+ status,
378
+ context: contextForCallback
379
+ });
380
+ if (result) updatedValues = mergeDeep(updatedValues, result);
381
+ }
382
+
383
+ if (propertyCallbacks?.beforeSave) {
384
+ const result = await propertyCallbacks.beforeSave({
385
+ collection: resolvedCollection as EntityCollection<M>,
386
+ path,
387
+ entityId,
388
+ values: updatedValues,
389
+ previousValues: previousValuesForHistory,
390
+ status,
391
+ context: contextForCallback
392
+ });
393
+ if (result) updatedValues = mergeDeep(updatedValues, result);
394
+ }
395
+
396
+ }
397
+
398
+ try {
399
+ let savedEntity = await this.entityService.saveEntity<M>(
400
+ path,
401
+ updatedValues,
402
+ entityId,
403
+ resolvedCollection?.databaseId
404
+ );
405
+
406
+ if (savedEntity && (callbacks?.afterRead || propertyCallbacks?.afterRead)) {
407
+ if (callbacks?.afterRead) {
408
+ savedEntity = await callbacks.afterRead({
409
+ collection: resolvedCollection as EntityCollection<M>,
410
+ path,
411
+ entity: savedEntity,
412
+ context: contextForCallback
413
+ }) ?? savedEntity;
414
+ }
415
+ if (propertyCallbacks?.afterRead) {
416
+ savedEntity = await propertyCallbacks.afterRead({
417
+ collection: resolvedCollection as EntityCollection<M>,
418
+ path,
419
+ entity: savedEntity,
420
+ context: contextForCallback
421
+ }) as Entity<M> ?? savedEntity;
422
+ }
423
+ }
424
+
425
+ if (callbacks?.afterSave || propertyCallbacks?.afterSave) {
426
+ if (callbacks?.afterSave) {
427
+ await callbacks.afterSave({
428
+ collection: resolvedCollection as EntityCollection<M>,
429
+ path,
430
+ entityId: savedEntity.id,
431
+ values: savedEntity.values,
432
+ previousValues: previousValuesForHistory,
433
+ status,
434
+ context: contextForCallback
435
+ });
436
+ }
437
+ if (propertyCallbacks?.afterSave) {
438
+ await propertyCallbacks.afterSave({
439
+ collection: resolvedCollection as EntityCollection<M>,
440
+ path,
441
+ entityId: savedEntity.id,
442
+ values: savedEntity.values,
443
+ previousValues: previousValuesForHistory,
444
+ status,
445
+ context: contextForCallback
446
+ });
447
+ }
448
+ }
449
+
450
+ // Record entity history (fire-and-forget, never blocks the save)
451
+ if (this.historyService && resolvedCollection?.history) {
452
+ this.historyService.recordHistory({
453
+ tableName: path,
454
+ entityId: savedEntity.id.toString(),
455
+ action: status === "new" ? "create" : "update",
456
+ values: savedEntity.values as Record<string, unknown>,
457
+ previousValues: previousValuesForHistory as Record<string, unknown> | undefined,
458
+ updatedBy: this.user?.uid
459
+ });
460
+ }
461
+
462
+ // Notify real-time subscribers (deferred if inside a transaction)
463
+ if (this._deferNotifications) {
464
+ this._pendingNotifications.push({
465
+ path,
466
+ entityId: savedEntity.id.toString(),
467
+ entity: savedEntity,
468
+ databaseId: resolvedCollection?.databaseId
469
+ });
470
+ } else {
471
+ await this.realtimeService.notifyEntityUpdate(
472
+ path,
473
+ savedEntity.id.toString(),
474
+ savedEntity,
475
+ resolvedCollection?.databaseId
476
+ );
477
+ }
478
+
479
+ return savedEntity;
480
+ } catch (error) {
481
+ if (callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
482
+ if (callbacks?.afterSaveError) {
483
+ await callbacks.afterSaveError({
484
+ collection: resolvedCollection as EntityCollection<M>,
485
+ path,
486
+ entityId: entityId || "unknown",
487
+ values: updatedValues,
488
+ previousValues: undefined,
489
+ status,
490
+ context: contextForCallback
491
+ });
492
+ }
493
+ if (propertyCallbacks?.afterSaveError) {
494
+ await propertyCallbacks.afterSaveError({
495
+ collection: resolvedCollection as EntityCollection<M>,
496
+ path,
497
+ entityId: entityId || "unknown",
498
+ values: updatedValues,
499
+ previousValues: undefined,
500
+ status,
501
+ context: contextForCallback
502
+ });
503
+ }
504
+ }
505
+ throw error;
506
+ }
507
+ }
508
+
509
+ async deleteEntity<M extends Record<string, unknown>>({
510
+ entity,
511
+ collection
512
+ }: DeleteEntityProps<M>): Promise<void> {
513
+
514
+ // Resolve from backend registry to restore callbacks lost during WebSocket serialization
515
+ const { collection: resolvedCollection, callbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, entity.path);
516
+
517
+ const contextForCallback = {
518
+ user: this.user,
519
+ driver: this,
520
+ data: this.data
521
+ } as unknown as RebaseCallContext;
522
+
523
+ if (callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
524
+ if (callbacks?.beforeDelete) {
525
+ await callbacks.beforeDelete({
526
+ collection: resolvedCollection as EntityCollection<M>,
527
+ path: entity.path,
528
+ entityId: entity.id,
529
+ entity,
530
+ context: contextForCallback
531
+ });
532
+ }
533
+ if (propertyCallbacks?.beforeDelete) {
534
+ await propertyCallbacks.beforeDelete({
535
+ collection: resolvedCollection as EntityCollection<M>,
536
+ path: entity.path,
537
+ entityId: entity.id,
538
+ entity,
539
+ context: contextForCallback
540
+ });
541
+ }
542
+ }
543
+
544
+ await this.entityService.deleteEntity(
545
+ entity.path,
546
+ entity.id,
547
+ entity.databaseId || resolvedCollection?.databaseId
548
+ );
549
+
550
+ if (callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
551
+ if (callbacks?.afterDelete) {
552
+ await callbacks.afterDelete({
553
+ collection: resolvedCollection as EntityCollection<M>,
554
+ path: entity.path,
555
+ entityId: entity.id,
556
+ entity,
557
+ context: contextForCallback
558
+ });
559
+ }
560
+ if (propertyCallbacks?.afterDelete) {
561
+ await propertyCallbacks.afterDelete({
562
+ collection: resolvedCollection as EntityCollection<M>,
563
+ path: entity.path,
564
+ entityId: entity.id,
565
+ entity,
566
+ context: contextForCallback
567
+ });
568
+ }
569
+ }
570
+
571
+ // Record delete history (fire-and-forget)
572
+ if (this.historyService && resolvedCollection?.history) {
573
+ this.historyService.recordHistory({
574
+ tableName: entity.path,
575
+ entityId: entity.id.toString(),
576
+ action: "delete",
577
+ values: entity.values as Record<string, unknown>,
578
+ updatedBy: this.user?.uid
579
+ });
580
+ }
581
+
582
+ // Notify real-time subscribers (deferred if inside a transaction)
583
+ if (this._deferNotifications) {
584
+ this._pendingNotifications.push({
585
+ path: entity.path,
586
+ entityId: entity.id.toString(),
587
+ entity: null,
588
+ databaseId: entity.databaseId || resolvedCollection?.databaseId
589
+ });
590
+ } else {
591
+ await this.realtimeService.notifyEntityUpdate(
592
+ entity.path,
593
+ entity.id.toString(),
594
+ null,
595
+ entity.databaseId || resolvedCollection?.databaseId
596
+ );
597
+ }
598
+
599
+ }
600
+
601
+ async checkUniqueField(
602
+ path: string,
603
+ name: string,
604
+ value: unknown,
605
+ entityId?: string,
606
+ collection?: EntityCollection
607
+ ): Promise<boolean> {
608
+ return this.entityService.checkUniqueField(
609
+ path,
610
+ name,
611
+ value,
612
+ entityId,
613
+ collection?.databaseId
614
+ );
615
+ }
616
+
617
+
618
+ async countEntities<M extends Record<string, unknown>>({
619
+ path,
620
+ collection,
621
+ filter,
622
+ searchString
623
+ }: FetchCollectionProps<M>): Promise<number> {
624
+ return this.entityService.countEntities(
625
+ path,
626
+ { filter,
627
+ searchString }
628
+ );
629
+ }
630
+
631
+ private getTargetDb(databaseName?: string): DrizzleClient {
632
+ if (!databaseName || databaseName === this.poolManager?.defaultDatabaseName) {
633
+ return this.db;
634
+ }
635
+ if (!this.poolManager) {
636
+ throw new Error(
637
+ "Cross-database execution requires adminConnectionString to be configured in the backend."
638
+ );
639
+ }
640
+ return this.poolManager.getDrizzle(databaseName);
641
+ }
642
+
643
+ async executeSql(sqlText: string, options?: { database?: string, role?: string }): Promise<Record<string, unknown>[]> {
644
+ if (!options?.database && !options?.role) {
645
+ return this.entityService.executeSql(sqlText);
646
+ }
647
+
648
+ const targetDb = this.getTargetDb(options?.database);
649
+
650
+ try {
651
+ if (options?.role) {
652
+ const safeRole = options.role.replace(/"/g, '""');
653
+ return await targetDb.transaction(async (tx) => {
654
+ await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
655
+ const result = await tx.execute(drizzleSql.raw(sqlText));
656
+ return result.rows as Record<string, unknown>[];
657
+ });
658
+ }
659
+
660
+ const result = await targetDb.execute(drizzleSql.raw(sqlText));
661
+ return result.rows as Record<string, unknown>[];
662
+ } catch (error: unknown) {
663
+ const msg = error instanceof Error ? error.message : String(error);
664
+ // Provide a user-friendly message for connection/auth errors
665
+ if (msg.includes("pg_hba.conf") || msg.includes("no encryption") || msg.includes("connection refused")) {
666
+ const dbName = options?.database || "unknown";
667
+ 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.`);
668
+ }
669
+ throw error;
670
+ }
671
+ }
672
+
673
+ async fetchAvailableDatabases(): Promise<string[]> {
674
+ // Exclude template databases, Cloud SQL internal databases, and the default 'postgres' system db
675
+ const result = await this.executeSql(
676
+ `SELECT datname FROM pg_database
677
+ WHERE datistemplate = false
678
+ AND datname NOT IN ('postgres', 'cloudsqladmin', '_cloudsqladmin')
679
+ ORDER BY datname;`
680
+ );
681
+ const databases = result.map((r: Record<string, unknown>) => r.datname as string);
682
+ // Ensure the current connected database is always first in the list
683
+ const currentDb = this.poolManager?.defaultDatabaseName;
684
+ if (currentDb && !databases.includes(currentDb)) {
685
+ databases.unshift(currentDb);
686
+ } else if (currentDb) {
687
+ // Move it to the front
688
+ const idx = databases.indexOf(currentDb);
689
+ if (idx > 0) {
690
+ databases.splice(idx, 1);
691
+ databases.unshift(currentDb);
692
+ }
693
+ }
694
+ return databases;
695
+ }
696
+
697
+ async fetchAvailableRoles(): Promise<string[]> {
698
+ const result = await this.executeSql("SELECT rolname FROM pg_roles;");
699
+ return result.map((r: Record<string, unknown>) => r.rolname as string);
700
+ }
701
+
702
+ async fetchCurrentDatabase(): Promise<string | undefined> {
703
+ return this.poolManager?.defaultDatabaseName;
704
+ }
705
+
706
+ /**
707
+ * Fetch public tables that are not yet mapped to a collection.
708
+ * Excludes internal tables (_rebase_*, _auth_*, auth tables, etc.)
709
+ * and junction/connection tables used for many-to-many relations.
710
+ */
711
+ async fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]> {
712
+ const result = await this.executeSql(`
713
+ SELECT table_name
714
+ FROM information_schema.tables
715
+ WHERE table_schema = 'public'
716
+ AND table_type = 'BASE TABLE'
717
+ ORDER BY table_name;
718
+ `);
719
+
720
+ const internalPrefixes = ["_rebase_", "_auth_"];
721
+ const internalExact = [
722
+ "users", "roles", "user_roles", "refresh_tokens",
723
+ "password_reset_tokens", "email_verification_tokens"
724
+ ];
725
+
726
+ const allTables = result
727
+ .map((r: Record<string, unknown>) => r.table_name as string)
728
+ .filter((name: string) => {
729
+ if (internalPrefixes.some(prefix => name.startsWith(prefix))) return false;
730
+ if (internalExact.includes(name)) return false;
731
+ return true;
732
+ });
733
+
734
+ // Detect junction tables: tables where every column is part of a foreign key.
735
+ // These are typically many-to-many connection tables and shouldn't be suggested.
736
+ let junctionTables = new Set<string>();
737
+ try {
738
+ const junctionResult = await this.executeSql(`
739
+ SELECT t.table_name
740
+ FROM information_schema.tables t
741
+ WHERE t.table_schema = 'public'
742
+ AND t.table_type = 'BASE TABLE'
743
+ AND NOT EXISTS (
744
+ -- Find columns that are NOT part of any foreign key
745
+ SELECT 1
746
+ FROM information_schema.columns c
747
+ WHERE c.table_schema = t.table_schema
748
+ AND c.table_name = t.table_name
749
+ AND c.column_name NOT IN (
750
+ SELECT kcu.column_name
751
+ FROM information_schema.key_column_usage kcu
752
+ JOIN information_schema.table_constraints tc
753
+ ON tc.constraint_name = kcu.constraint_name
754
+ AND tc.table_schema = kcu.table_schema
755
+ WHERE tc.constraint_type = 'FOREIGN KEY'
756
+ AND kcu.table_schema = t.table_schema
757
+ AND kcu.table_name = t.table_name
758
+ )
759
+ );
760
+ `);
761
+ junctionTables = new Set(junctionResult.map((r: Record<string, unknown>) => r.table_name as string));
762
+ } catch (e) {
763
+ console.warn("Could not detect junction tables:", e);
764
+ }
765
+
766
+ const filteredTables = allTables.filter(name => !junctionTables.has(name));
767
+
768
+ if (!mappedPaths || mappedPaths.length === 0) return filteredTables;
769
+
770
+ const mappedSet = new Set(mappedPaths.map(p => p.toLowerCase()));
771
+ return filteredTables.filter((name: string) => !mappedSet.has(name.toLowerCase()));
772
+ }
773
+
774
+
775
+ /**
776
+ * Fetch metadata for a given table from information_schema (columns, policies, constraints).
777
+ */
778
+ async fetchTableMetadata(tableName: string): Promise<TableMetadata> {
779
+ // Sanitize table name as defense-in-depth (parameterized below)
780
+ const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
781
+
782
+ // 1. Fetch Columns
783
+ const result = await this.db.execute(drizzleSql`
784
+ SELECT column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length
785
+ FROM information_schema.columns
786
+ WHERE table_schema = 'public'
787
+ AND table_name = ${safeName}
788
+ ORDER BY ordinal_position
789
+ `);
790
+ const columns = result.rows as Record<string, unknown>[];
791
+
792
+ // Also fetch enum values for any USER-DEFINED columns
793
+ const enumColumns = columns.filter((c) => c.data_type === "USER-DEFINED");
794
+ if (enumColumns.length > 0) {
795
+ for (const col of enumColumns) {
796
+ try {
797
+ const enumResult = await this.db.execute(drizzleSql`
798
+ SELECT e.enumlabel
799
+ FROM pg_type t
800
+ JOIN pg_enum e ON t.oid = e.enumtypid
801
+ WHERE t.typname = ${col.udt_name as string}
802
+ ORDER BY e.enumsortorder
803
+ `);
804
+ col.enum_values = (enumResult.rows as Record<string, unknown>[]).map(e => e.enumlabel);
805
+ } catch {
806
+ col.enum_values = [];
807
+ }
808
+ }
809
+ }
810
+ const typedColumns = columns as unknown as TableColumnInfo[];
811
+
812
+ // 2. Fetch Foreign Keys
813
+ const fkResult = await this.db.execute(drizzleSql`
814
+ SELECT
815
+ kcu.column_name as column_name,
816
+ ccu.table_name AS foreign_table_name,
817
+ ccu.column_name AS foreign_column_name
818
+ FROM
819
+ information_schema.table_constraints AS tc
820
+ JOIN information_schema.key_column_usage AS kcu
821
+ ON tc.constraint_name = kcu.constraint_name
822
+ AND tc.table_schema = kcu.table_schema
823
+ JOIN information_schema.constraint_column_usage AS ccu
824
+ ON ccu.constraint_name = tc.constraint_name
825
+ AND ccu.table_schema = tc.table_schema
826
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ${safeName};
827
+ `);
828
+ const foreignKeys = fkResult.rows as unknown as TableForeignKeyInfo[];
829
+
830
+ // 3. Fetch Junction Tables (Many-to-Many)
831
+ // A simple junction table is one that has foreign keys to our table and other tables
832
+ const junctionsResult = await this.db.execute(drizzleSql`
833
+ SELECT
834
+ tc1.table_name as junction_table_name,
835
+ kcu1.column_name as source_column_name,
836
+ ccu2.table_name as target_table_name,
837
+ kcu2.column_name as target_column_name
838
+ FROM information_schema.table_constraints tc1
839
+ JOIN information_schema.key_column_usage kcu1 ON tc1.constraint_name = kcu1.constraint_name
840
+ JOIN information_schema.constraint_column_usage ccu1 ON ccu1.constraint_name = tc1.constraint_name
841
+ JOIN information_schema.table_constraints tc2 ON tc1.table_name = tc2.table_name AND tc2.constraint_type = 'FOREIGN KEY'
842
+ JOIN information_schema.key_column_usage kcu2 ON tc2.constraint_name = kcu2.constraint_name
843
+ JOIN information_schema.constraint_column_usage ccu2 ON ccu2.constraint_name = tc2.constraint_name
844
+ WHERE tc1.constraint_type = 'FOREIGN KEY'
845
+ AND ccu1.table_name = ${safeName}
846
+ AND ccu2.table_name != ${safeName};
847
+ `);
848
+ const junctions = junctionsResult.rows as unknown as TableJunctionInfo[];
849
+
850
+ // 4. Fetch RLS Policies
851
+ const policiesResult = await this.db.execute(drizzleSql`
852
+ SELECT
853
+ polname as policy_name,
854
+ polcmd as cmd,
855
+ polroles::regrole[]::text[] as roles,
856
+ pg_get_expr(polqual, polrelid) as qual,
857
+ pg_get_expr(polwithcheck, polrelid) as with_check
858
+ FROM pg_policy
859
+ WHERE polrelid = (SELECT oid FROM pg_class WHERE relname = ${safeName} AND relnamespace = 'public'::regnamespace);
860
+ `);
861
+ const policies = policiesResult.rows as unknown as TablePolicyInfo[];
862
+
863
+ return {
864
+ columns: typedColumns,
865
+ foreignKeys,
866
+ junctions,
867
+ policies
868
+ };
869
+ }
870
+
871
+ private generateSubscriptionId(): string {
872
+ return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
873
+ }
874
+
875
+ /**
876
+ * Create a new delegate instance with authenticated context.
877
+ * Starts a transaction and sets the current_user_id and current_user_roles
878
+ * configuration parameters for PostgreSQL Row Level Security.
879
+ */
880
+ async withAuth(user: User): Promise<DataDriver> {
881
+ return new AuthenticatedPostgresBackendDriver(this, user);
882
+ }
883
+ }
884
+
885
+ export class AuthenticatedPostgresBackendDriver implements DataDriver {
886
+ key = "postgres";
887
+ initialised = true;
888
+
889
+ public user: User;
890
+ public data: RebaseData;
891
+
892
+ constructor(
893
+ public delegate: PostgresBackendDriver,
894
+ user: User
895
+ ) {
896
+ this.user = user;
897
+ this.data = buildRebaseData(this);
898
+
899
+ // Delegate admin ops to the base driver (no RLS wrapping for admin)
900
+ this.admin = delegate.admin;
901
+ }
902
+
903
+ /**
904
+ * Typed admin capabilities — delegates to the base driver.
905
+ */
906
+ admin: DatabaseAdmin;
907
+
908
+ private async withTransaction<T>(
909
+ operation: (delegate: PostgresBackendDriver) => Promise<T>
910
+ ): Promise<T> {
911
+ const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
912
+
913
+ const result = await this.delegate.db.transaction(async (tx) => {
914
+ let userId = this.user?.uid;
915
+ if (!userId) {
916
+ console.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object:", this.user);
917
+ userId = "anonymous";
918
+ }
919
+
920
+ const userRoles = this.user?.roles ?? [];
921
+ if (!this.user?.roles) {
922
+ console.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object:", this.user);
923
+ }
924
+ const normalizedRoles = userRoles.map((r: unknown) =>
925
+ typeof r === "string" ? r : (r as Record<string, unknown>)?.id ?? String(r)
926
+ );
927
+ const rolesString = normalizedRoles.join(",");
928
+
929
+ await tx.execute(drizzleSql`
930
+ SELECT
931
+ set_config('app.user_id', ${userId}, true),
932
+ set_config('app.user_roles', ${rolesString}, true),
933
+ set_config('app.jwt', ${JSON.stringify({ sub: userId,
934
+ roles: userRoles })}, true)
935
+ `);
936
+
937
+ const txEntityService = new EntityService(tx, this.delegate.registry);
938
+ const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
939
+
940
+ txDelegate.entityService = txEntityService;
941
+ txDelegate._deferNotifications = true;
942
+ txDelegate._pendingNotifications = pendingNotifications;
943
+
944
+ return await operation(txDelegate);
945
+ });
946
+
947
+ for (const notification of pendingNotifications) {
948
+ try {
949
+ await this.delegate.realtimeService.notifyEntityUpdate(
950
+ notification.path,
951
+ notification.entityId,
952
+ notification.entity,
953
+ notification.databaseId
954
+ );
955
+ } catch (e) {
956
+ console.error("[DataDriver] Error flushing deferred notification:", e);
957
+ }
958
+ }
959
+
960
+ return result;
961
+ }
962
+
963
+ async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Entity<M>[]> {
964
+ return this.withTransaction((delegate) => delegate.fetchCollection(props));
965
+ }
966
+
967
+ /**
968
+ * Injects the authenticated user's context into the most recently
969
+ * registered realtime subscription so RLS-aware polling can apply.
970
+ */
971
+ private injectAuthContext(unsubscribe: () => void): () => void {
972
+ const authContext = { userId: this.user?.uid || "anonymous",
973
+ roles: this.user?.roles ?? [] };
974
+ const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
975
+ const lastEntry = entries[entries.length - 1];
976
+ const lastSub = lastEntry?.[1] as Record<string, unknown> | undefined;
977
+ if (lastSub && lastSub.clientId === "driver") {
978
+ lastSub.authContext = authContext;
979
+ }
980
+ return unsubscribe;
981
+ }
982
+
983
+ listenCollection<M extends Record<string, unknown>>(props: ListenCollectionProps<M>): () => void {
984
+ return this.injectAuthContext(this.delegate.listenCollection(props));
985
+ }
986
+
987
+ async fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
988
+ return this.withTransaction((delegate) => delegate.fetchEntity(props));
989
+ }
990
+
991
+ listenEntity<M extends Record<string, unknown>>(props: ListenEntityProps<M>): () => void {
992
+ return this.injectAuthContext(this.delegate.listenEntity(props));
993
+ }
994
+
995
+ async saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>> {
996
+ return this.withTransaction((delegate) => delegate.saveEntity(props));
997
+ }
998
+
999
+ async deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void> {
1000
+ return this.withTransaction((delegate) => delegate.deleteEntity(props));
1001
+ }
1002
+
1003
+ async checkUniqueField(
1004
+ path: string,
1005
+ name: string,
1006
+ value: unknown,
1007
+ entityId?: string,
1008
+ collection?: EntityCollection
1009
+ ): Promise<boolean> {
1010
+ return this.withTransaction((delegate) => delegate.checkUniqueField(path, name, value, entityId, collection));
1011
+ }
1012
+
1013
+ async countEntities<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
1014
+ return this.withTransaction((delegate) => delegate.countEntities(props));
1015
+ }
1016
+
1017
+ }