@rebasepro/server-postgresql 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common/src/collections/default-collections.d.ts +12 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/index.es.js +844 -160
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +842 -158
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +1 -1
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +1 -0
- package/dist/server-postgresql/src/auth/services.d.ts +43 -1
- package/dist/server-postgresql/src/connection.d.ts +25 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +2382 -35
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +4 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +2 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +20 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +18 -0
- package/dist/types/src/controllers/auth.d.ts +2 -24
- package/dist/types/src/controllers/client.d.ts +0 -3
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data_driver.d.ts +18 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +2 -4
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +2 -2
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +6 -4
- package/dist/types/src/users/roles.d.ts +0 -8
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +4 -2
- package/src/PostgresBootstrapper.ts +27 -8
- package/src/auth/ensure-tables.ts +79 -17
- package/src/auth/services.ts +292 -23
- package/src/connection.ts +77 -0
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-schema.ts +80 -14
- package/src/schema/generate-drizzle-schema.ts +6 -6
- package/src/services/EntityFetchService.ts +69 -10
- package/src/services/entityService.ts +2 -0
- package/src/services/realtimeService.ts +214 -2
- package/src/utils/drizzle-conditions.ts +74 -2
- package/src/websocket.ts +10 -2
- package/test/auth-services.test.ts +15 -28
- package/test/drizzle-conditions.test.ts +168 -0
- package/vite.config.ts +1 -1
|
@@ -41,6 +41,14 @@ type RealTimeListenEntityProps = ListenEntityProps & { subscriptionId: string };
|
|
|
41
41
|
*/
|
|
42
42
|
export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
43
43
|
private clients = new Map<string, WebSocket>();
|
|
44
|
+
|
|
45
|
+
// Broadcast channels: channel name → set of client IDs
|
|
46
|
+
private channels = new Map<string, Set<string>>();
|
|
47
|
+
|
|
48
|
+
// Presence: channel → Map<clientId, { state, lastSeen }>
|
|
49
|
+
private presence = new Map<string, Map<string, { state: Record<string, unknown>; lastSeen: number }>>();
|
|
50
|
+
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
51
|
+
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
44
52
|
private entityService: EntityService;
|
|
45
53
|
// Enhanced subscriptions storage with full request parameters
|
|
46
54
|
private _subscriptions = new Map<string, {
|
|
@@ -237,9 +245,24 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
237
245
|
}
|
|
238
246
|
}
|
|
239
247
|
}
|
|
248
|
+
|
|
249
|
+
// Remove from all broadcast channels
|
|
250
|
+
for (const [channel, members] of this.channels.entries()) {
|
|
251
|
+
if (members.has(clientId)) {
|
|
252
|
+
members.delete(clientId);
|
|
253
|
+
this.removePresence(clientId, channel);
|
|
254
|
+
if (members.size === 0) this.channels.delete(channel);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Remove from all presence channels
|
|
259
|
+
for (const [channel] of this.presence) {
|
|
260
|
+
this.removePresence(clientId, channel);
|
|
261
|
+
}
|
|
240
262
|
}
|
|
241
263
|
|
|
242
264
|
private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
265
|
+
const payload = message.payload as Record<string, unknown> | undefined;
|
|
243
266
|
switch (message.type) {
|
|
244
267
|
case "subscribe_collection":
|
|
245
268
|
await this.handleCollectionSubscription(clientId, message.payload as RealTimeListenCollectionProps, authContext);
|
|
@@ -250,6 +273,40 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
250
273
|
case "unsubscribe":
|
|
251
274
|
await this.handleUnsubscribe(clientId, message.subscriptionId!);
|
|
252
275
|
break;
|
|
276
|
+
|
|
277
|
+
// ── Broadcast Channels ──
|
|
278
|
+
case "join_channel":
|
|
279
|
+
this.joinChannel(clientId, payload?.channel as string);
|
|
280
|
+
break;
|
|
281
|
+
case "leave_channel":
|
|
282
|
+
this.leaveChannel(clientId, payload?.channel as string);
|
|
283
|
+
break;
|
|
284
|
+
case "broadcast":
|
|
285
|
+
this.broadcastToChannel(
|
|
286
|
+
clientId,
|
|
287
|
+
payload?.channel as string,
|
|
288
|
+
payload?.event as string,
|
|
289
|
+
payload?.payload
|
|
290
|
+
);
|
|
291
|
+
break;
|
|
292
|
+
|
|
293
|
+
// ── Presence ──
|
|
294
|
+
case "presence_track":
|
|
295
|
+
// Auto-join the channel so presence works without a separate join
|
|
296
|
+
this.joinChannel(clientId, payload?.channel as string);
|
|
297
|
+
this.trackPresence(
|
|
298
|
+
clientId,
|
|
299
|
+
payload?.channel as string,
|
|
300
|
+
payload?.state as Record<string, unknown> ?? {}
|
|
301
|
+
);
|
|
302
|
+
break;
|
|
303
|
+
case "presence_untrack":
|
|
304
|
+
this.removePresence(clientId, payload?.channel as string);
|
|
305
|
+
break;
|
|
306
|
+
case "presence_state":
|
|
307
|
+
this.sendPresenceState(clientId, payload?.channel as string);
|
|
308
|
+
break;
|
|
309
|
+
|
|
253
310
|
default:
|
|
254
311
|
this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
|
|
255
312
|
}
|
|
@@ -839,6 +896,153 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
839
896
|
|
|
840
897
|
return parentPaths;
|
|
841
898
|
}
|
|
899
|
+
|
|
900
|
+
// =============================================================================
|
|
901
|
+
// Broadcast Channels
|
|
902
|
+
// =============================================================================
|
|
903
|
+
|
|
904
|
+
/** Join a broadcast channel */
|
|
905
|
+
joinChannel(clientId: string, channel: string): void {
|
|
906
|
+
if (!this.channels.has(channel)) {
|
|
907
|
+
this.channels.set(channel, new Set());
|
|
908
|
+
}
|
|
909
|
+
this.channels.get(channel)!.add(clientId);
|
|
910
|
+
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Leave a broadcast channel */
|
|
914
|
+
leaveChannel(clientId: string, channel: string): void {
|
|
915
|
+
const members = this.channels.get(channel);
|
|
916
|
+
if (members) {
|
|
917
|
+
members.delete(clientId);
|
|
918
|
+
if (members.size === 0) this.channels.delete(channel);
|
|
919
|
+
}
|
|
920
|
+
// Also remove presence
|
|
921
|
+
this.removePresence(clientId, channel);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Broadcast a message to all clients in a channel except sender */
|
|
925
|
+
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void {
|
|
926
|
+
const members = this.channels.get(channel);
|
|
927
|
+
if (!members) return;
|
|
928
|
+
|
|
929
|
+
const message = JSON.stringify({
|
|
930
|
+
type: "broadcast",
|
|
931
|
+
channel,
|
|
932
|
+
event,
|
|
933
|
+
payload
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
for (const memberId of members) {
|
|
937
|
+
if (memberId === clientId) continue; // Don't echo back to sender
|
|
938
|
+
const ws = this.clients.get(memberId);
|
|
939
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
940
|
+
ws.send(message);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// =============================================================================
|
|
946
|
+
// Presence
|
|
947
|
+
// =============================================================================
|
|
948
|
+
|
|
949
|
+
/** Track presence in a channel */
|
|
950
|
+
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void {
|
|
951
|
+
if (!this.presence.has(channel)) {
|
|
952
|
+
this.presence.set(channel, new Map());
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const channelPresence = this.presence.get(channel)!;
|
|
956
|
+
channelPresence.set(clientId, { state, lastSeen: Date.now() });
|
|
957
|
+
|
|
958
|
+
// Broadcast join / state update to channel
|
|
959
|
+
this.broadcastPresenceDiff(channel, { [clientId]: state }, {});
|
|
960
|
+
|
|
961
|
+
// Start cleanup interval if not running
|
|
962
|
+
this.ensurePresenceCleanup();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Remove presence from a channel */
|
|
966
|
+
removePresence(clientId: string, channel: string): void {
|
|
967
|
+
const channelPresence = this.presence.get(channel);
|
|
968
|
+
if (!channelPresence) return;
|
|
969
|
+
|
|
970
|
+
const entry = channelPresence.get(clientId);
|
|
971
|
+
if (entry) {
|
|
972
|
+
channelPresence.delete(clientId);
|
|
973
|
+
this.broadcastPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (channelPresence.size === 0) {
|
|
977
|
+
this.presence.delete(channel);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Send full presence state to a specific client */
|
|
982
|
+
sendPresenceState(clientId: string, channel: string): void {
|
|
983
|
+
const channelPresence = this.presence.get(channel);
|
|
984
|
+
const presences: Record<string, Record<string, unknown>> = {};
|
|
985
|
+
|
|
986
|
+
if (channelPresence) {
|
|
987
|
+
for (const [id, { state }] of channelPresence) {
|
|
988
|
+
presences[id] = state;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const ws = this.clients.get(clientId);
|
|
993
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
994
|
+
ws.send(JSON.stringify({
|
|
995
|
+
type: "presence_state",
|
|
996
|
+
channel,
|
|
997
|
+
presences
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Broadcast presence diff (joins/leaves) to channel */
|
|
1003
|
+
private broadcastPresenceDiff(
|
|
1004
|
+
channel: string,
|
|
1005
|
+
joins: Record<string, Record<string, unknown>>,
|
|
1006
|
+
leaves: Record<string, Record<string, unknown>>
|
|
1007
|
+
): void {
|
|
1008
|
+
const members = this.channels.get(channel);
|
|
1009
|
+
if (!members) return;
|
|
1010
|
+
|
|
1011
|
+
const message = JSON.stringify({
|
|
1012
|
+
type: "presence_diff",
|
|
1013
|
+
channel,
|
|
1014
|
+
joins,
|
|
1015
|
+
leaves
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
for (const memberId of members) {
|
|
1019
|
+
const ws = this.clients.get(memberId);
|
|
1020
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1021
|
+
ws.send(message);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Periodic cleanup for stale presences */
|
|
1027
|
+
private ensurePresenceCleanup(): void {
|
|
1028
|
+
if (this.presenceInterval) return;
|
|
1029
|
+
this.presenceInterval = setInterval(() => {
|
|
1030
|
+
const now = Date.now();
|
|
1031
|
+
for (const [channel, channelPresence] of this.presence) {
|
|
1032
|
+
for (const [clientId, entry] of channelPresence) {
|
|
1033
|
+
if (now - entry.lastSeen > RealtimeService.PRESENCE_TIMEOUT_MS) {
|
|
1034
|
+
this.removePresence(clientId, channel);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// Stop interval if no presences tracked
|
|
1039
|
+
if (this.presence.size === 0 && this.presenceInterval) {
|
|
1040
|
+
clearInterval(this.presenceInterval);
|
|
1041
|
+
this.presenceInterval = undefined;
|
|
1042
|
+
}
|
|
1043
|
+
}, 10000); // Check every 10s
|
|
1044
|
+
}
|
|
1045
|
+
|
|
842
1046
|
// =============================================================================
|
|
843
1047
|
// Lifecycle / Cleanup
|
|
844
1048
|
// =============================================================================
|
|
@@ -865,10 +1069,18 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
865
1069
|
this._subscriptions.clear();
|
|
866
1070
|
this.subscriptionCallbacks.clear();
|
|
867
1071
|
|
|
868
|
-
// 3.
|
|
1072
|
+
// 3. Clear broadcast channels and presence
|
|
1073
|
+
this.channels.clear();
|
|
1074
|
+
this.presence.clear();
|
|
1075
|
+
if (this.presenceInterval) {
|
|
1076
|
+
clearInterval(this.presenceInterval);
|
|
1077
|
+
this.presenceInterval = undefined;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// 4. Disconnect the dedicated LISTEN client
|
|
869
1081
|
await this.stopListening();
|
|
870
1082
|
|
|
871
|
-
//
|
|
1083
|
+
// 5. Drop client references (don't close — server.close drains them)
|
|
872
1084
|
this.clients.clear();
|
|
873
1085
|
|
|
874
1086
|
this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
|
|
@@ -38,7 +38,15 @@ export class DrizzleConditionBuilder {
|
|
|
38
38
|
if (!filterParam) continue;
|
|
39
39
|
|
|
40
40
|
const [op, value] = filterParam as [WhereFilterOp, any];
|
|
41
|
-
|
|
41
|
+
let fieldColumn = table[field as keyof typeof table] as AnyPgColumn;
|
|
42
|
+
|
|
43
|
+
if (!fieldColumn) {
|
|
44
|
+
// Fallback for relations (e.g. project -> project_id)
|
|
45
|
+
const relationKey = `${field}_id`;
|
|
46
|
+
if (relationKey in table) {
|
|
47
|
+
fieldColumn = table[relationKey as keyof typeof table] as AnyPgColumn;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
|
|
43
51
|
if (!fieldColumn) {
|
|
44
52
|
console.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
|
|
@@ -87,8 +95,22 @@ export class DrizzleConditionBuilder {
|
|
|
87
95
|
}
|
|
88
96
|
return null;
|
|
89
97
|
case "array-contains":
|
|
90
|
-
// For JSONB arrays
|
|
98
|
+
// For JSONB arrays: checks if the column contains the given value
|
|
99
|
+
return sql`${column} @> ${JSON.stringify([value])}`;
|
|
100
|
+
case "array-contains-any":
|
|
101
|
+
// For JSONB arrays: checks if the column contains any of the given values
|
|
102
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
103
|
+
// Use the ?| operator for JSONB overlap with text array
|
|
104
|
+
const textValues = value.map(v => String(v));
|
|
105
|
+
return sql`${column} ?| array[${sql.join(textValues.map(v => sql`${v}`), sql`, `)}]`;
|
|
106
|
+
}
|
|
107
|
+
// Single value fallback: treat as array-contains
|
|
91
108
|
return sql`${column} @> ${JSON.stringify([value])}`;
|
|
109
|
+
case "not-in":
|
|
110
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
111
|
+
return sql`${column} NOT IN (${sql.join(value.map(v => sql`${v}`), sql`, `)})`;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
92
114
|
default:
|
|
93
115
|
console.warn(`Unsupported filter operation: ${op}`);
|
|
94
116
|
return null;
|
|
@@ -1005,6 +1027,55 @@ export class DrizzleConditionBuilder {
|
|
|
1005
1027
|
return null;
|
|
1006
1028
|
}
|
|
1007
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Build vector similarity search expressions for pgvector.
|
|
1033
|
+
*
|
|
1034
|
+
* Returns:
|
|
1035
|
+
* - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
|
|
1036
|
+
* - `filter`: optional WHERE clause for distance threshold
|
|
1037
|
+
* - `distanceSelect`: SQL expression for selecting the distance as `_distance`
|
|
1038
|
+
*/
|
|
1039
|
+
static buildVectorSearchConditions(
|
|
1040
|
+
table: PgTable<any>,
|
|
1041
|
+
vectorSearch: {
|
|
1042
|
+
property: string;
|
|
1043
|
+
vector: number[];
|
|
1044
|
+
distance?: "cosine" | "l2" | "inner_product";
|
|
1045
|
+
threshold?: number;
|
|
1046
|
+
}
|
|
1047
|
+
): { orderBy: SQL; filter?: SQL; distanceSelect: SQL } {
|
|
1048
|
+
const column = table[vectorSearch.property as keyof typeof table] as AnyPgColumn;
|
|
1049
|
+
if (!column) {
|
|
1050
|
+
throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1054
|
+
const distanceFn = vectorSearch.distance || "cosine";
|
|
1055
|
+
|
|
1056
|
+
let operator: string;
|
|
1057
|
+
switch (distanceFn) {
|
|
1058
|
+
case "cosine":
|
|
1059
|
+
operator = "<=>";
|
|
1060
|
+
break;
|
|
1061
|
+
case "l2":
|
|
1062
|
+
operator = "<->";
|
|
1063
|
+
break;
|
|
1064
|
+
case "inner_product":
|
|
1065
|
+
operator = "<#>";
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const distanceExpr = sql`${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)}`;
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
orderBy: distanceExpr,
|
|
1073
|
+
filter: vectorSearch.threshold != null
|
|
1074
|
+
? sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)}) < ${vectorSearch.threshold}`
|
|
1075
|
+
: undefined,
|
|
1076
|
+
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1008
1079
|
}
|
|
1009
1080
|
|
|
1010
1081
|
/**
|
|
@@ -1012,3 +1083,4 @@ export class DrizzleConditionBuilder {
|
|
|
1012
1083
|
* This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
|
|
1013
1084
|
*/
|
|
1014
1085
|
export const PostgresConditionBuilder = DrizzleConditionBuilder;
|
|
1086
|
+
|
package/src/websocket.ts
CHANGED
|
@@ -550,8 +550,16 @@ colors: true }));
|
|
|
550
550
|
// Route subscription messages to RealtimeService
|
|
551
551
|
case "subscribe_collection":
|
|
552
552
|
case "subscribe_entity":
|
|
553
|
-
case "unsubscribe":
|
|
554
|
-
|
|
553
|
+
case "unsubscribe":
|
|
554
|
+
// Broadcast channels
|
|
555
|
+
case "join_channel":
|
|
556
|
+
case "leave_channel":
|
|
557
|
+
case "broadcast":
|
|
558
|
+
// Presence
|
|
559
|
+
case "presence_track":
|
|
560
|
+
case "presence_untrack":
|
|
561
|
+
case "presence_state": {
|
|
562
|
+
wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
|
|
555
563
|
// Attach auth context from the WS session so RLS-aware refetches work
|
|
556
564
|
const session = clientSessions.get(clientId);
|
|
557
565
|
const authContext = session?.user
|
|
@@ -37,6 +37,7 @@ function mockUserData(overrides: Partial<UserData>): UserData {
|
|
|
37
37
|
createdAt: expect.any(Date),
|
|
38
38
|
updatedAt: expect.any(Date),
|
|
39
39
|
metadata: {},
|
|
40
|
+
isAnonymous: false,
|
|
40
41
|
...overrides
|
|
41
42
|
};
|
|
42
43
|
}
|
|
@@ -348,14 +349,12 @@ describe("Auth Services", () => {
|
|
|
348
349
|
name: "Admin",
|
|
349
350
|
is_admin: true,
|
|
350
351
|
default_permissions: null,
|
|
351
|
-
collection_permissions: null,
|
|
352
|
-
config: null },
|
|
352
|
+
collection_permissions: null },
|
|
353
353
|
{ id: "editor",
|
|
354
354
|
name: "Editor",
|
|
355
355
|
is_admin: false,
|
|
356
356
|
default_permissions: { edit: true },
|
|
357
|
-
collection_permissions: null
|
|
358
|
-
config: null }
|
|
357
|
+
collection_permissions: null }
|
|
359
358
|
]
|
|
360
359
|
});
|
|
361
360
|
|
|
@@ -367,8 +366,7 @@ describe("Auth Services", () => {
|
|
|
367
366
|
name: "Admin",
|
|
368
367
|
isAdmin: true,
|
|
369
368
|
defaultPermissions: null,
|
|
370
|
-
collectionPermissions: null
|
|
371
|
-
config: null
|
|
369
|
+
collectionPermissions: null
|
|
372
370
|
});
|
|
373
371
|
});
|
|
374
372
|
});
|
|
@@ -381,8 +379,7 @@ describe("Auth Services", () => {
|
|
|
381
379
|
name: "Admin",
|
|
382
380
|
is_admin: true,
|
|
383
381
|
default_permissions: null,
|
|
384
|
-
collection_permissions: null
|
|
385
|
-
config: null }
|
|
382
|
+
collection_permissions: null }
|
|
386
383
|
]
|
|
387
384
|
});
|
|
388
385
|
|
|
@@ -426,8 +423,7 @@ describe("Auth Services", () => {
|
|
|
426
423
|
name: "Admin",
|
|
427
424
|
is_admin: true,
|
|
428
425
|
default_permissions: null,
|
|
429
|
-
collection_permissions: null
|
|
430
|
-
config: null }]
|
|
426
|
+
collection_permissions: null }]
|
|
431
427
|
});
|
|
432
428
|
|
|
433
429
|
const result = await userService.getUserWithRoles("user-123");
|
|
@@ -438,8 +434,7 @@ describe("Auth Services", () => {
|
|
|
438
434
|
name: "Admin",
|
|
439
435
|
isAdmin: true,
|
|
440
436
|
defaultPermissions: null,
|
|
441
|
-
collectionPermissions: null
|
|
442
|
-
config: null }]
|
|
437
|
+
collectionPermissions: null }]
|
|
443
438
|
});
|
|
444
439
|
});
|
|
445
440
|
|
|
@@ -491,8 +486,7 @@ describe("Auth Services", () => {
|
|
|
491
486
|
name: "Admin",
|
|
492
487
|
is_admin: true,
|
|
493
488
|
default_permissions: null,
|
|
494
|
-
collection_permissions: null
|
|
495
|
-
config: null }]
|
|
489
|
+
collection_permissions: null }]
|
|
496
490
|
});
|
|
497
491
|
|
|
498
492
|
const result = await roleService.getRoleById("admin");
|
|
@@ -502,8 +496,7 @@ config: null }]
|
|
|
502
496
|
name: "Admin",
|
|
503
497
|
isAdmin: true,
|
|
504
498
|
defaultPermissions: null,
|
|
505
|
-
collectionPermissions: null
|
|
506
|
-
config: null
|
|
499
|
+
collectionPermissions: null
|
|
507
500
|
});
|
|
508
501
|
});
|
|
509
502
|
|
|
@@ -524,14 +517,12 @@ config: null }]
|
|
|
524
517
|
name: "Admin",
|
|
525
518
|
is_admin: true,
|
|
526
519
|
default_permissions: null,
|
|
527
|
-
collection_permissions: null,
|
|
528
|
-
config: null },
|
|
520
|
+
collection_permissions: null },
|
|
529
521
|
{ id: "editor",
|
|
530
522
|
name: "Editor",
|
|
531
523
|
is_admin: false,
|
|
532
524
|
default_permissions: null,
|
|
533
|
-
collection_permissions: null
|
|
534
|
-
config: null }
|
|
525
|
+
collection_permissions: null }
|
|
535
526
|
]
|
|
536
527
|
});
|
|
537
528
|
|
|
@@ -548,15 +539,13 @@ config: null }
|
|
|
548
539
|
name: "Custom Role",
|
|
549
540
|
is_admin: false,
|
|
550
541
|
default_permissions: null,
|
|
551
|
-
collection_permissions: null
|
|
552
|
-
config: null }]
|
|
542
|
+
collection_permissions: null }]
|
|
553
543
|
});
|
|
554
544
|
|
|
555
545
|
const role = await roleService.createRole({
|
|
556
546
|
id: "custom",
|
|
557
547
|
name: "Custom Role",
|
|
558
|
-
defaultPermissions: null
|
|
559
|
-
config: null
|
|
548
|
+
defaultPermissions: null
|
|
560
549
|
});
|
|
561
550
|
|
|
562
551
|
expect(role.id).toBe("custom");
|
|
@@ -571,15 +560,13 @@ config: null }]
|
|
|
571
560
|
name: "Admin",
|
|
572
561
|
is_admin: true,
|
|
573
562
|
default_permissions: null,
|
|
574
|
-
collection_permissions: null
|
|
575
|
-
config: null }] })
|
|
563
|
+
collection_permissions: null }] })
|
|
576
564
|
.mockResolvedValueOnce({ rows: [] })
|
|
577
565
|
.mockResolvedValueOnce({ rows: [{ id: "admin",
|
|
578
566
|
name: "Super Admin",
|
|
579
567
|
is_admin: true,
|
|
580
568
|
default_permissions: null,
|
|
581
|
-
collection_permissions: null
|
|
582
|
-
config: null }] });
|
|
569
|
+
collection_permissions: null }] });
|
|
583
570
|
|
|
584
571
|
const result = await roleService.updateRole("admin", { name: "Super Admin" });
|
|
585
572
|
|
|
@@ -893,3 +893,171 @@ describe("DrizzleConditionBuilder - Many-to-Many Relations", () => {
|
|
|
893
893
|
});
|
|
894
894
|
});
|
|
895
895
|
});
|
|
896
|
+
|
|
897
|
+
describe("DrizzleConditionBuilder - Filter Operators", () => {
|
|
898
|
+
// Mock table for filter tests
|
|
899
|
+
const mockUsersTable = pgTable("users", {
|
|
900
|
+
id: serial("id").primaryKey(),
|
|
901
|
+
name: varchar("name").notNull(),
|
|
902
|
+
email: varchar("email").notNull(),
|
|
903
|
+
age: integer("age")
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
describe("buildSingleFilterCondition - array-contains", () => {
|
|
907
|
+
it("should generate a non-null condition for a single value", () => {
|
|
908
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
909
|
+
mockUsersTable.name,
|
|
910
|
+
"array-contains",
|
|
911
|
+
"featured"
|
|
912
|
+
);
|
|
913
|
+
expect(condition).not.toBeNull();
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
describe("buildSingleFilterCondition - array-contains-any", () => {
|
|
918
|
+
it("should generate a non-null condition for an array of values", () => {
|
|
919
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
920
|
+
mockUsersTable.name,
|
|
921
|
+
"array-contains-any",
|
|
922
|
+
["featured", "popular", "trending"]
|
|
923
|
+
);
|
|
924
|
+
expect(condition).not.toBeNull();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it("should fallback to array-contains for a single (non-array) value", () => {
|
|
928
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
929
|
+
mockUsersTable.name,
|
|
930
|
+
"array-contains-any",
|
|
931
|
+
"featured"
|
|
932
|
+
);
|
|
933
|
+
expect(condition).not.toBeNull();
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
describe("buildSingleFilterCondition - not-in", () => {
|
|
938
|
+
it("should generate a non-null condition for an array of values", () => {
|
|
939
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
940
|
+
mockUsersTable.age,
|
|
941
|
+
"not-in",
|
|
942
|
+
[1, 2, 3]
|
|
943
|
+
);
|
|
944
|
+
expect(condition).not.toBeNull();
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it("should return null for empty array", () => {
|
|
948
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
949
|
+
mockUsersTable.age,
|
|
950
|
+
"not-in",
|
|
951
|
+
[]
|
|
952
|
+
);
|
|
953
|
+
expect(condition).toBeNull();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("should return null for non-array value", () => {
|
|
957
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
958
|
+
mockUsersTable.age,
|
|
959
|
+
"not-in",
|
|
960
|
+
42
|
|
961
|
+
);
|
|
962
|
+
expect(condition).toBeNull();
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
describe("buildSingleFilterCondition - existing operators", () => {
|
|
967
|
+
it("should generate a condition for equality", () => {
|
|
968
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
969
|
+
mockUsersTable.name,
|
|
970
|
+
"==",
|
|
971
|
+
"alice"
|
|
972
|
+
);
|
|
973
|
+
expect(condition).not.toBeNull();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("should generate IS NULL for equality with null", () => {
|
|
977
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
978
|
+
mockUsersTable.name,
|
|
979
|
+
"==",
|
|
980
|
+
null
|
|
981
|
+
);
|
|
982
|
+
expect(condition).not.toBeNull();
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("should generate IS NOT NULL for inequality with null", () => {
|
|
986
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
987
|
+
mockUsersTable.name,
|
|
988
|
+
"!=",
|
|
989
|
+
null
|
|
990
|
+
);
|
|
991
|
+
expect(condition).not.toBeNull();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("should handle in operator with array", () => {
|
|
995
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
996
|
+
mockUsersTable.age,
|
|
997
|
+
"in",
|
|
998
|
+
[18, 21, 25]
|
|
999
|
+
);
|
|
1000
|
+
expect(condition).not.toBeNull();
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it("should return null for in operator with empty array", () => {
|
|
1004
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
1005
|
+
mockUsersTable.age,
|
|
1006
|
+
"in",
|
|
1007
|
+
[]
|
|
1008
|
+
);
|
|
1009
|
+
expect(condition).toBeNull();
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("should warn and return null for unsupported operators", () => {
|
|
1013
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
1014
|
+
const condition = DrizzleConditionBuilder.buildSingleFilterCondition(
|
|
1015
|
+
mockUsersTable.age,
|
|
1016
|
+
"unknown-op" as any,
|
|
1017
|
+
42
|
|
1018
|
+
);
|
|
1019
|
+
expect(condition).toBeNull();
|
|
1020
|
+
expect(warnSpy).toHaveBeenCalledWith("Unsupported filter operation: unknown-op");
|
|
1021
|
+
warnSpy.mockRestore();
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
describe("buildFilterConditions - integration with array operators", () => {
|
|
1026
|
+
it("should build filter with array-contains operator", () => {
|
|
1027
|
+
const conditions = DrizzleConditionBuilder.buildFilterConditions(
|
|
1028
|
+
{ name: ["array-contains", "featured"] },
|
|
1029
|
+
mockUsersTable,
|
|
1030
|
+
"users"
|
|
1031
|
+
);
|
|
1032
|
+
expect(conditions).toHaveLength(1);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("should build filter with array-contains-any operator", () => {
|
|
1036
|
+
const conditions = DrizzleConditionBuilder.buildFilterConditions(
|
|
1037
|
+
{ name: ["array-contains-any", ["featured", "popular"]] },
|
|
1038
|
+
mockUsersTable,
|
|
1039
|
+
"users"
|
|
1040
|
+
);
|
|
1041
|
+
expect(conditions).toHaveLength(1);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("should build filter with not-in operator", () => {
|
|
1045
|
+
const conditions = DrizzleConditionBuilder.buildFilterConditions(
|
|
1046
|
+
{ age: ["not-in", [1, 2, 3]] },
|
|
1047
|
+
mockUsersTable,
|
|
1048
|
+
"users"
|
|
1049
|
+
);
|
|
1050
|
+
expect(conditions).toHaveLength(1);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("should skip not-in filter with empty array", () => {
|
|
1054
|
+
const conditions = DrizzleConditionBuilder.buildFilterConditions(
|
|
1055
|
+
{ age: ["not-in", []] },
|
|
1056
|
+
mockUsersTable,
|
|
1057
|
+
"users"
|
|
1058
|
+
);
|
|
1059
|
+
expect(conditions).toHaveLength(0);
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
package/vite.config.ts
CHANGED
|
@@ -33,7 +33,7 @@ const isExternal = (id: string) => {
|
|
|
33
33
|
// Externalize only deps the consumer app explicitly installs
|
|
34
34
|
if (CONSUMER_EXTERNALS.some(ext => id === ext || id.startsWith(ext + "/"))) return true;
|
|
35
35
|
// Externalize Node built-ins
|
|
36
|
-
if (["fs", "path", "url", "util", "crypto", "http", "https", "net", "tls", "stream", "events", "os", "child_process", "buffer", "assert", "node:"].some(b => id === b || id.startsWith("node:") || id.startsWith(b + "/"))) return true;
|
|
36
|
+
if (["fs", "path", "url", "util", "crypto", "http", "https", "net", "tls", "stream", "events", "os", "child_process", "buffer", "assert", "dns", "zlib", "querystring", "node:"].some(b => id === b || id.startsWith("node:") || id.startsWith(b + "/"))) return true;
|
|
37
37
|
// Inline everything else (jsonwebtoken, ws, zod, etc.)
|
|
38
38
|
return false;
|
|
39
39
|
};
|