@rebasepro/server-postgresql 0.2.3 → 0.2.5

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 (63) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +9 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/index.es.js +1075 -470
  5. package/dist/index.es.js.map +1 -1
  6. package/dist/index.umd.js +1071 -466
  7. package/dist/index.umd.js.map +1 -1
  8. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +3 -1
  9. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +1 -0
  10. package/dist/server-postgresql/src/auth/services.d.ts +48 -31
  11. package/dist/server-postgresql/src/connection.d.ts +25 -0
  12. package/dist/server-postgresql/src/schema/auth-schema.d.ts +2135 -41
  13. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +4 -0
  14. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +4 -0
  15. package/dist/server-postgresql/src/services/entityService.d.ts +6 -0
  16. package/dist/server-postgresql/src/services/realtimeService.d.ts +20 -0
  17. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +18 -0
  18. package/dist/types/src/controllers/auth.d.ts +4 -26
  19. package/dist/types/src/controllers/client.d.ts +25 -43
  20. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  21. package/dist/types/src/controllers/data.d.ts +4 -0
  22. package/dist/types/src/controllers/data_driver.d.ts +23 -0
  23. package/dist/types/src/controllers/registry.d.ts +5 -4
  24. package/dist/types/src/rebase_context.d.ts +1 -1
  25. package/dist/types/src/types/auth_adapter.d.ts +5 -60
  26. package/dist/types/src/types/backend.d.ts +2 -2
  27. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  28. package/dist/types/src/types/collections.d.ts +0 -4
  29. package/dist/types/src/types/component_ref.d.ts +1 -1
  30. package/dist/types/src/types/cron.d.ts +1 -1
  31. package/dist/types/src/types/entity_views.d.ts +1 -0
  32. package/dist/types/src/types/export_import.d.ts +1 -1
  33. package/dist/types/src/types/formex.d.ts +2 -2
  34. package/dist/types/src/types/properties.d.ts +9 -7
  35. package/dist/types/src/types/translations.d.ts +28 -12
  36. package/dist/types/src/types/user_management_delegate.d.ts +22 -57
  37. package/dist/types/src/users/index.d.ts +0 -1
  38. package/dist/types/src/users/user.d.ts +0 -1
  39. package/package.json +6 -6
  40. package/src/PostgresBackendDriver.ts +14 -2
  41. package/src/PostgresBootstrapper.ts +30 -20
  42. package/src/auth/ensure-tables.ts +116 -103
  43. package/src/auth/services.ts +347 -177
  44. package/src/connection.ts +77 -0
  45. package/src/data-transformer.ts +2 -2
  46. package/src/schema/auth-schema.ts +85 -75
  47. package/src/schema/doctor.ts +44 -3
  48. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  49. package/src/schema/generate-drizzle-schema.ts +6 -6
  50. package/src/schema/introspect-db-logic.ts +7 -0
  51. package/src/services/EntityFetchService.ts +69 -10
  52. package/src/services/EntityPersistService.ts +9 -0
  53. package/src/services/entityService.ts +9 -0
  54. package/src/services/realtimeService.ts +214 -2
  55. package/src/utils/drizzle-conditions.ts +74 -2
  56. package/src/websocket.ts +10 -2
  57. package/test/auth-services.test.ts +10 -166
  58. package/test/doctor.test.ts +6 -2
  59. package/test/drizzle-conditions.test.ts +168 -0
  60. package/vite.config.ts +1 -1
  61. package/dist/server-postgresql/src/schema/default-collections.d.ts +0 -2
  62. package/dist/types/src/users/roles.d.ts +0 -22
  63. package/src/schema/default-collections.ts +0 -69
@@ -1,6 +1,7 @@
1
1
  import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
4
+ import type { VectorSearchParams } from "@rebasepro/types";
4
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
5
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
6
7
  import {
@@ -698,6 +699,7 @@ export class EntityFetchService {
698
699
  startAfter?: Record<string, unknown>;
699
700
  searchString?: string;
700
701
  databaseId?: string;
702
+ vectorSearch?: VectorSearchParams;
701
703
  } = {}
702
704
  ): Promise<Entity<M>[]> {
703
705
  const collection = getCollectionByPath(collectionPath, this.registry);
@@ -722,7 +724,9 @@ export class EntityFetchService {
722
724
  const withConfig = this.buildWithConfig(collection);
723
725
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
724
726
 
725
- if (qb && !options.searchString && !hasRelations) {
727
+ // Skip db.query path when vectorSearch is present — it doesn't support
728
+ // custom SELECT expressions needed for the _distance column.
729
+ if (qb && !options.searchString && !hasRelations && !options.vectorSearch) {
726
730
  try {
727
731
  const queryOpts = this.buildDrizzleQueryOptions<M>(
728
732
  table, idField, idInfo, options, collectionPath, undefined
@@ -746,7 +750,15 @@ export class EntityFetchService {
746
750
  }
747
751
 
748
752
  // Fallback: db.select + processEntityResults (N+1 for relations)
749
- let query = this.db.select().from(table).$dynamic();
753
+ // When vectorSearch is present, add _distance to the SELECT.
754
+ let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
755
+ if (options.vectorSearch) {
756
+ vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
757
+ }
758
+
759
+ let query = vectorMeta
760
+ ? this.db.select({ table_row: table, _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
761
+ : this.db.select().from(table).$dynamic();
750
762
  const allConditions: SQL[] = [];
751
763
 
752
764
  if (options.searchString) {
@@ -762,13 +774,21 @@ export class EntityFetchService {
762
774
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
763
775
  }
764
776
 
777
+ // Vector distance threshold filter
778
+ if (vectorMeta?.filter) {
779
+ allConditions.push(vectorMeta.filter);
780
+ }
781
+
765
782
  if (allConditions.length > 0) {
766
783
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
767
784
  if (finalCondition) query = query.where(finalCondition);
768
785
  }
769
786
 
770
787
  const orderExpressions = [];
771
- if (options.orderBy) {
788
+ // Vector search overrides ORDER BY with distance (ascending = closest first)
789
+ if (vectorMeta) {
790
+ orderExpressions.push(asc(vectorMeta.orderBy));
791
+ } else if (options.orderBy) {
772
792
  const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
773
793
  if (orderByField) {
774
794
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
@@ -786,13 +806,24 @@ export class EntityFetchService {
786
806
  }
787
807
  }
788
808
 
789
- const limitValue = options.searchString ? (options.limit || 50) : options.limit;
809
+ const limitValue = options.vectorSearch
810
+ ? (options.limit || 10)
811
+ : options.searchString ? (options.limit || 50) : options.limit;
790
812
  if (limitValue) query = query.limit(limitValue);
791
813
 
792
814
  // Offset (numeric pagination)
793
815
  if (options.offset && options.offset > 0) query = query.offset(options.offset);
794
816
 
795
- const results = await query;
817
+ const rawResults = await query;
818
+
819
+ // When vector search is active, unwrap the nested select shape and
820
+ // attach _distance to each entity's values.
821
+ const results = vectorMeta
822
+ ? (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
823
+ ...r.table_row,
824
+ _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
825
+ }))
826
+ : rawResults as Record<string, unknown>[];
796
827
 
797
828
  return this.processEntityResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
798
829
  }
@@ -919,6 +950,7 @@ export class EntityFetchService {
919
950
  startAfter?: Record<string, unknown>;
920
951
  searchString?: string;
921
952
  databaseId?: string;
953
+ vectorSearch?: VectorSearchParams;
922
954
  } = {}
923
955
  ): Promise<Entity<M>[]> {
924
956
  // Handle multi-segment paths by resolving through relations
@@ -1164,6 +1196,7 @@ export class EntityFetchService {
1164
1196
  startAfter?: Record<string, unknown>;
1165
1197
  searchString?: string;
1166
1198
  databaseId?: string;
1199
+ vectorSearch?: VectorSearchParams;
1167
1200
  } = {},
1168
1201
  include?: string[]
1169
1202
  ): Promise<Record<string, unknown>[]> {
@@ -1181,7 +1214,8 @@ export class EntityFetchService {
1181
1214
  const tableName = getTableName(table);
1182
1215
 
1183
1216
  const qb = this.getQueryBuilder(tableName);
1184
- if (qb && !options.searchString) {
1217
+ // Skip db.query path when vectorSearch is present — needs custom SELECT
1218
+ if (qb && !options.searchString && !options.vectorSearch) {
1185
1219
  try {
1186
1220
  const withConfig = (include && include.length > 0)
1187
1221
  ? this.buildWithConfig(collection, include)
@@ -1389,6 +1423,7 @@ export class EntityFetchService {
1389
1423
  offset?: number;
1390
1424
  startAfter?: Record<string, unknown>;
1391
1425
  searchString?: string;
1426
+ vectorSearch?: VectorSearchParams;
1392
1427
  } = {}
1393
1428
  ): Promise<Record<string, unknown>[]> {
1394
1429
  const collection = getCollectionByPath(collectionPath, this.registry);
@@ -1397,7 +1432,14 @@ export class EntityFetchService {
1397
1432
  const idInfo = idInfoArray[0];
1398
1433
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1399
1434
 
1400
- let query = this.db.select().from(table).$dynamic();
1435
+ let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
1436
+ if (options.vectorSearch) {
1437
+ vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1438
+ }
1439
+
1440
+ let query = vectorMeta
1441
+ ? this.db.select({ table_row: table, _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1442
+ : this.db.select().from(table).$dynamic();
1401
1443
  const allConditions: SQL[] = [];
1402
1444
 
1403
1445
  if (options.searchString) {
@@ -1413,13 +1455,19 @@ export class EntityFetchService {
1413
1455
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
1414
1456
  }
1415
1457
 
1458
+ if (vectorMeta?.filter) {
1459
+ allConditions.push(vectorMeta.filter);
1460
+ }
1461
+
1416
1462
  if (allConditions.length > 0) {
1417
1463
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1418
1464
  if (finalCondition) query = query.where(finalCondition);
1419
1465
  }
1420
1466
 
1421
1467
  const orderExpressions = [];
1422
- if (options.orderBy) {
1468
+ if (vectorMeta) {
1469
+ orderExpressions.push(asc(vectorMeta.orderBy));
1470
+ } else if (options.orderBy) {
1423
1471
  const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1424
1472
  if (orderByField) {
1425
1473
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
@@ -1428,13 +1476,24 @@ export class EntityFetchService {
1428
1476
  orderExpressions.push(desc(idField));
1429
1477
  if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
1430
1478
 
1431
- const limitValue = options.searchString ? (options.limit || 50) : options.limit;
1479
+ const limitValue = options.vectorSearch
1480
+ ? (options.limit || 10)
1481
+ : options.searchString ? (options.limit || 50) : options.limit;
1432
1482
  if (limitValue) query = query.limit(limitValue);
1433
1483
 
1434
1484
  // Offset (numeric pagination)
1435
1485
  if (options.offset && options.offset > 0) query = query.offset(options.offset);
1436
1486
 
1437
- return await query as Record<string, unknown>[];
1487
+ const rawResults = await query;
1488
+
1489
+ if (vectorMeta) {
1490
+ return (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
1491
+ ...r.table_row,
1492
+ _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
1493
+ }));
1494
+ }
1495
+
1496
+ return rawResults as Record<string, unknown>[];
1438
1497
  }
1439
1498
 
1440
1499
  /**
@@ -53,6 +53,15 @@ export class EntityPersistService {
53
53
  .where(eq(idField, parsedId));
54
54
  }
55
55
 
56
+ /**
57
+ * Delete all entities from a collection
58
+ */
59
+ async deleteAll(collectionPath: string, _databaseId?: string): Promise<void> {
60
+ const collection = getCollectionByPath(collectionPath, this.registry);
61
+ const table = getTableForCollection(collection, this.registry);
62
+ await this.db.delete(table);
63
+ }
64
+
56
65
  /**
57
66
  * Save an entity (create or update)
58
67
  */
@@ -1,5 +1,6 @@
1
1
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
2
  import { Entity, FilterValues } from "@rebasepro/types";
3
+ import type { VectorSearchParams } from "@rebasepro/types";
3
4
  import { EntityFetchService } from "./EntityFetchService";
4
5
  import { EntityPersistService } from "./EntityPersistService";
5
6
  import { RelationService } from "./RelationService";
@@ -66,6 +67,7 @@ export class EntityService implements EntityRepository {
66
67
  startAfter?: Record<string, unknown>;
67
68
  searchString?: string;
68
69
  databaseId?: string;
70
+ vectorSearch?: VectorSearchParams;
69
71
  } = {}
70
72
  ): Promise<Entity<M>[]> {
71
73
  return this.fetchService.fetchCollection<M>(collectionPath, options);
@@ -167,6 +169,13 @@ export class EntityService implements EntityRepository {
167
169
  return this.persistService.deleteEntity(collectionPath, entityId, databaseId);
168
170
  }
169
171
 
172
+ /**
173
+ * Delete all entities from a collection
174
+ */
175
+ async deleteAll(collectionPath: string, databaseId?: string): Promise<void> {
176
+ return this.persistService.deleteAll(collectionPath, databaseId);
177
+ }
178
+
170
179
 
171
180
  /**
172
181
  * Execute raw SQL
@@ -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