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