@rebasepro/server-postgresql 0.2.1 → 0.2.4

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 (57) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/data/query_builder.d.ts +51 -0
  4. package/dist/common/src/index.d.ts +1 -0
  5. package/dist/common/src/util/permissions.d.ts +1 -0
  6. package/dist/index.es.js +1202 -369
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +1200 -367
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +1 -1
  11. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +1 -0
  12. package/dist/server-postgresql/src/auth/services.d.ts +43 -1
  13. package/dist/server-postgresql/src/connection.d.ts +25 -0
  14. package/dist/server-postgresql/src/schema/auth-schema.d.ts +2382 -35
  15. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +4 -0
  16. package/dist/server-postgresql/src/services/entityService.d.ts +2 -0
  17. package/dist/server-postgresql/src/services/realtimeService.d.ts +20 -0
  18. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +18 -0
  19. package/dist/types/src/controllers/auth.d.ts +2 -24
  20. package/dist/types/src/controllers/client.d.ts +0 -3
  21. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  22. package/dist/types/src/controllers/data.d.ts +21 -0
  23. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  24. package/dist/types/src/controllers/registry.d.ts +5 -4
  25. package/dist/types/src/rebase_context.d.ts +1 -1
  26. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  27. package/dist/types/src/types/collections.d.ts +0 -4
  28. package/dist/types/src/types/component_ref.d.ts +1 -1
  29. package/dist/types/src/types/cron.d.ts +1 -1
  30. package/dist/types/src/types/entity_views.d.ts +1 -0
  31. package/dist/types/src/types/export_import.d.ts +1 -1
  32. package/dist/types/src/types/formex.d.ts +2 -2
  33. package/dist/types/src/types/properties.d.ts +2 -2
  34. package/dist/types/src/types/translations.d.ts +28 -12
  35. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  36. package/dist/types/src/users/roles.d.ts +0 -8
  37. package/package.json +7 -6
  38. package/src/PostgresBackendDriver.ts +13 -7
  39. package/src/PostgresBootstrapper.ts +27 -8
  40. package/src/auth/ensure-tables.ts +79 -17
  41. package/src/auth/services.ts +292 -23
  42. package/src/cli.ts +5 -0
  43. package/src/connection.ts +77 -0
  44. package/src/data-transformer.ts +2 -2
  45. package/src/schema/auth-schema.ts +80 -14
  46. package/src/schema/default-collections.ts +1 -0
  47. package/src/schema/doctor.ts +82 -41
  48. package/src/schema/generate-drizzle-schema.ts +6 -6
  49. package/src/services/EntityFetchService.ts +69 -10
  50. package/src/services/entityService.ts +2 -0
  51. package/src/services/realtimeService.ts +214 -2
  52. package/src/utils/drizzle-conditions.ts +74 -2
  53. package/src/websocket.ts +10 -2
  54. package/test/auth-services.test.ts +15 -28
  55. package/test/drizzle-conditions.test.ts +168 -0
  56. package/test/postgresDataDriver.test.ts +130 -1
  57. 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. Disconnect the dedicated LISTEN client
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
- // 4. Drop client references (don't close — server.close drains them)
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
- const fieldColumn = table[field as keyof typeof table] as AnyPgColumn;
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
- wsDebug("🔄 [WebSocket Server] Routing subscription message to RealtimeService:", type);
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
+