@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.
- package/dist/common/src/collections/default-collections.d.ts +9 -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 +1075 -470
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1071 -466
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +3 -1
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +1 -0
- package/dist/server-postgresql/src/auth/services.d.ts +48 -31
- package/dist/server-postgresql/src/connection.d.ts +25 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +2135 -41
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +4 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +4 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +6 -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 +4 -26
- package/dist/types/src/controllers/client.d.ts +25 -43
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data.d.ts +4 -0
- package/dist/types/src/controllers/data_driver.d.ts +23 -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 +5 -60
- package/dist/types/src/types/backend.d.ts +2 -2
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- 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 +9 -7
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +22 -57
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +14 -2
- package/src/PostgresBootstrapper.ts +30 -20
- package/src/auth/ensure-tables.ts +116 -103
- package/src/auth/services.ts +347 -177
- package/src/connection.ts +77 -0
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-schema.ts +85 -75
- package/src/schema/doctor.ts +44 -3
- package/src/schema/generate-drizzle-schema-logic.ts +33 -3
- package/src/schema/generate-drizzle-schema.ts +6 -6
- package/src/schema/introspect-db-logic.ts +7 -0
- package/src/services/EntityFetchService.ts +69 -10
- package/src/services/EntityPersistService.ts +9 -0
- package/src/services/entityService.ts +9 -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 +10 -166
- package/test/doctor.test.ts +6 -2
- package/test/drizzle-conditions.test.ts +168 -0
- package/vite.config.ts +1 -1
- package/dist/server-postgresql/src/schema/default-collections.d.ts +0 -2
- package/dist/types/src/users/roles.d.ts +0 -22
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|