@topgunbuild/client 0.7.0 → 0.8.1
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/index.d.mts +453 -2
- package/dist/index.d.ts +453 -2
- package/dist/index.js +782 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +780 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/LICENSE +0 -97
package/dist/index.mjs
CHANGED
|
@@ -709,6 +709,16 @@ var _SyncEngine = class _SyncEngine {
|
|
|
709
709
|
// ============================================
|
|
710
710
|
/** Message listeners for journal and other generic messages */
|
|
711
711
|
this.messageListeners = /* @__PURE__ */ new Set();
|
|
712
|
+
// ============================================
|
|
713
|
+
// Full-Text Search Methods (Phase 11.1a)
|
|
714
|
+
// ============================================
|
|
715
|
+
/** Pending search requests by requestId */
|
|
716
|
+
this.pendingSearchRequests = /* @__PURE__ */ new Map();
|
|
717
|
+
// ============================================
|
|
718
|
+
// Hybrid Query Support (Phase 12)
|
|
719
|
+
// ============================================
|
|
720
|
+
/** Active hybrid query subscriptions */
|
|
721
|
+
this.hybridQueries = /* @__PURE__ */ new Map();
|
|
712
722
|
if (!config.serverUrl && !config.connectionProvider) {
|
|
713
723
|
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
714
724
|
}
|
|
@@ -1594,6 +1604,21 @@ var _SyncEngine = class _SyncEngine {
|
|
|
1594
1604
|
this.conflictResolverClient.handleMergeRejected(message);
|
|
1595
1605
|
break;
|
|
1596
1606
|
}
|
|
1607
|
+
// ============ Full-Text Search Message Handlers (Phase 11.1a) ============
|
|
1608
|
+
case "SEARCH_RESP": {
|
|
1609
|
+
logger.debug({ requestId: message.payload?.requestId, resultCount: message.payload?.results?.length }, "Received SEARCH_RESP");
|
|
1610
|
+
this.handleSearchResponse(message.payload);
|
|
1611
|
+
break;
|
|
1612
|
+
}
|
|
1613
|
+
// ============ Live Search Message Handlers (Phase 11.1b) ============
|
|
1614
|
+
case "SEARCH_UPDATE": {
|
|
1615
|
+
logger.debug({
|
|
1616
|
+
subscriptionId: message.payload?.subscriptionId,
|
|
1617
|
+
key: message.payload?.key,
|
|
1618
|
+
type: message.payload?.type
|
|
1619
|
+
}, "Received SEARCH_UPDATE");
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1597
1622
|
}
|
|
1598
1623
|
if (message.timestamp) {
|
|
1599
1624
|
this.hlc.update(message.timestamp);
|
|
@@ -2357,6 +2382,65 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2357
2382
|
}
|
|
2358
2383
|
}
|
|
2359
2384
|
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Perform a one-shot BM25 search on the server.
|
|
2387
|
+
*
|
|
2388
|
+
* @param mapName Name of the map to search
|
|
2389
|
+
* @param query Search query text
|
|
2390
|
+
* @param options Search options (limit, minScore, boost)
|
|
2391
|
+
* @returns Promise resolving to search results
|
|
2392
|
+
*/
|
|
2393
|
+
async search(mapName, query, options) {
|
|
2394
|
+
if (!this.isAuthenticated()) {
|
|
2395
|
+
throw new Error("Not connected to server");
|
|
2396
|
+
}
|
|
2397
|
+
const requestId = crypto.randomUUID();
|
|
2398
|
+
return new Promise((resolve, reject) => {
|
|
2399
|
+
const timeout = setTimeout(() => {
|
|
2400
|
+
this.pendingSearchRequests.delete(requestId);
|
|
2401
|
+
reject(new Error("Search request timed out"));
|
|
2402
|
+
}, _SyncEngine.SEARCH_TIMEOUT);
|
|
2403
|
+
this.pendingSearchRequests.set(requestId, {
|
|
2404
|
+
resolve: (results) => {
|
|
2405
|
+
clearTimeout(timeout);
|
|
2406
|
+
resolve(results);
|
|
2407
|
+
},
|
|
2408
|
+
reject: (error) => {
|
|
2409
|
+
clearTimeout(timeout);
|
|
2410
|
+
reject(error);
|
|
2411
|
+
},
|
|
2412
|
+
timeout
|
|
2413
|
+
});
|
|
2414
|
+
const sent = this.sendMessage({
|
|
2415
|
+
type: "SEARCH",
|
|
2416
|
+
payload: {
|
|
2417
|
+
requestId,
|
|
2418
|
+
mapName,
|
|
2419
|
+
query,
|
|
2420
|
+
options
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
if (!sent) {
|
|
2424
|
+
this.pendingSearchRequests.delete(requestId);
|
|
2425
|
+
clearTimeout(timeout);
|
|
2426
|
+
reject(new Error("Failed to send search request"));
|
|
2427
|
+
}
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Handle search response from server.
|
|
2432
|
+
*/
|
|
2433
|
+
handleSearchResponse(payload) {
|
|
2434
|
+
const pending = this.pendingSearchRequests.get(payload.requestId);
|
|
2435
|
+
if (pending) {
|
|
2436
|
+
this.pendingSearchRequests.delete(payload.requestId);
|
|
2437
|
+
if (payload.error) {
|
|
2438
|
+
pending.reject(new Error(payload.error));
|
|
2439
|
+
} else {
|
|
2440
|
+
pending.resolve(payload.results);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2360
2444
|
// ============================================
|
|
2361
2445
|
// Conflict Resolver Client (Phase 5.05)
|
|
2362
2446
|
// ============================================
|
|
@@ -2367,9 +2451,156 @@ var _SyncEngine = class _SyncEngine {
|
|
|
2367
2451
|
getConflictResolverClient() {
|
|
2368
2452
|
return this.conflictResolverClient;
|
|
2369
2453
|
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Subscribe to a hybrid query (FTS + filter combination).
|
|
2456
|
+
*/
|
|
2457
|
+
subscribeToHybridQuery(query) {
|
|
2458
|
+
this.hybridQueries.set(query.id, query);
|
|
2459
|
+
const filter = query.getFilter();
|
|
2460
|
+
const mapName = query.getMapName();
|
|
2461
|
+
if (query.hasFTSPredicate() && this.stateMachine.getState() === "CONNECTED" /* CONNECTED */) {
|
|
2462
|
+
this.sendHybridQuerySubscription(query.id, mapName, filter);
|
|
2463
|
+
}
|
|
2464
|
+
this.runLocalHybridQuery(mapName, filter).then((results) => {
|
|
2465
|
+
query.onResult(results, "local");
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Unsubscribe from a hybrid query.
|
|
2470
|
+
*/
|
|
2471
|
+
unsubscribeFromHybridQuery(queryId) {
|
|
2472
|
+
const query = this.hybridQueries.get(queryId);
|
|
2473
|
+
if (query) {
|
|
2474
|
+
this.hybridQueries.delete(queryId);
|
|
2475
|
+
if (this.stateMachine.getState() === "CONNECTED" /* CONNECTED */ && query.hasFTSPredicate()) {
|
|
2476
|
+
this.sendMessage({
|
|
2477
|
+
type: "HYBRID_QUERY_UNSUBSCRIBE",
|
|
2478
|
+
payload: { subscriptionId: queryId }
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* Send hybrid query subscription to server.
|
|
2485
|
+
*/
|
|
2486
|
+
sendHybridQuerySubscription(queryId, mapName, filter) {
|
|
2487
|
+
this.sendMessage({
|
|
2488
|
+
type: "HYBRID_QUERY_SUBSCRIBE",
|
|
2489
|
+
payload: {
|
|
2490
|
+
subscriptionId: queryId,
|
|
2491
|
+
mapName,
|
|
2492
|
+
predicate: filter.predicate,
|
|
2493
|
+
where: filter.where,
|
|
2494
|
+
sort: filter.sort,
|
|
2495
|
+
limit: filter.limit,
|
|
2496
|
+
offset: filter.offset
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Run a local hybrid query (FTS + filter combination).
|
|
2502
|
+
* For FTS predicates, returns results with score = 0 (local-only mode).
|
|
2503
|
+
* Server provides actual FTS scoring.
|
|
2504
|
+
*/
|
|
2505
|
+
async runLocalHybridQuery(mapName, filter) {
|
|
2506
|
+
if (!this.storageAdapter) {
|
|
2507
|
+
return [];
|
|
2508
|
+
}
|
|
2509
|
+
const results = [];
|
|
2510
|
+
const allKeys = await this.storageAdapter.getAllKeys();
|
|
2511
|
+
const mapPrefix = `${mapName}:`;
|
|
2512
|
+
const entries = [];
|
|
2513
|
+
for (const fullKey of allKeys) {
|
|
2514
|
+
if (fullKey.startsWith(mapPrefix)) {
|
|
2515
|
+
const key = fullKey.substring(mapPrefix.length);
|
|
2516
|
+
const record = await this.storageAdapter.get(fullKey);
|
|
2517
|
+
if (record) {
|
|
2518
|
+
entries.push([key, record]);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
for (const [key, record] of entries) {
|
|
2523
|
+
if (record === null || record.value === null) continue;
|
|
2524
|
+
const value = record.value;
|
|
2525
|
+
if (filter.predicate) {
|
|
2526
|
+
const matches = evaluatePredicate(filter.predicate, value);
|
|
2527
|
+
if (!matches) continue;
|
|
2528
|
+
}
|
|
2529
|
+
if (filter.where) {
|
|
2530
|
+
let whereMatches = true;
|
|
2531
|
+
for (const [field, expected] of Object.entries(filter.where)) {
|
|
2532
|
+
if (value[field] !== expected) {
|
|
2533
|
+
whereMatches = false;
|
|
2534
|
+
break;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
if (!whereMatches) continue;
|
|
2538
|
+
}
|
|
2539
|
+
results.push({
|
|
2540
|
+
key,
|
|
2541
|
+
value,
|
|
2542
|
+
score: 0,
|
|
2543
|
+
// Local doesn't have FTS scoring
|
|
2544
|
+
matchedTerms: []
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
if (filter.sort) {
|
|
2548
|
+
results.sort((a, b) => {
|
|
2549
|
+
for (const [field, direction] of Object.entries(filter.sort)) {
|
|
2550
|
+
let valA;
|
|
2551
|
+
let valB;
|
|
2552
|
+
if (field === "_score") {
|
|
2553
|
+
valA = a.score ?? 0;
|
|
2554
|
+
valB = b.score ?? 0;
|
|
2555
|
+
} else if (field === "_key") {
|
|
2556
|
+
valA = a.key;
|
|
2557
|
+
valB = b.key;
|
|
2558
|
+
} else {
|
|
2559
|
+
valA = a.value[field];
|
|
2560
|
+
valB = b.value[field];
|
|
2561
|
+
}
|
|
2562
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
2563
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
2564
|
+
}
|
|
2565
|
+
return 0;
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
let sliced = results;
|
|
2569
|
+
if (filter.offset) {
|
|
2570
|
+
sliced = sliced.slice(filter.offset);
|
|
2571
|
+
}
|
|
2572
|
+
if (filter.limit) {
|
|
2573
|
+
sliced = sliced.slice(0, filter.limit);
|
|
2574
|
+
}
|
|
2575
|
+
return sliced;
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Handle hybrid query response from server.
|
|
2579
|
+
*/
|
|
2580
|
+
handleHybridQueryResponse(payload) {
|
|
2581
|
+
const query = this.hybridQueries.get(payload.subscriptionId);
|
|
2582
|
+
if (query) {
|
|
2583
|
+
query.onResult(payload.results, "server");
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Handle hybrid query delta update from server.
|
|
2588
|
+
*/
|
|
2589
|
+
handleHybridQueryDelta(payload) {
|
|
2590
|
+
const query = this.hybridQueries.get(payload.subscriptionId);
|
|
2591
|
+
if (query) {
|
|
2592
|
+
if (payload.type === "LEAVE") {
|
|
2593
|
+
query.onUpdate(payload.key, null);
|
|
2594
|
+
} else {
|
|
2595
|
+
query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2370
2599
|
};
|
|
2371
2600
|
/** Default timeout for entry processor requests (ms) */
|
|
2372
2601
|
_SyncEngine.PROCESSOR_TIMEOUT = 3e4;
|
|
2602
|
+
/** Default timeout for search requests (ms) */
|
|
2603
|
+
_SyncEngine.SEARCH_TIMEOUT = 3e4;
|
|
2373
2604
|
var SyncEngine = _SyncEngine;
|
|
2374
2605
|
|
|
2375
2606
|
// src/TopGunClient.ts
|
|
@@ -3035,6 +3266,446 @@ var EventJournalReader = class {
|
|
|
3035
3266
|
}
|
|
3036
3267
|
};
|
|
3037
3268
|
|
|
3269
|
+
// src/SearchHandle.ts
|
|
3270
|
+
var SearchHandle = class {
|
|
3271
|
+
constructor(syncEngine, mapName, query, options) {
|
|
3272
|
+
/** Current results map (key → result) */
|
|
3273
|
+
this.results = /* @__PURE__ */ new Map();
|
|
3274
|
+
/** Result change listeners */
|
|
3275
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
3276
|
+
/** Whether the handle has been disposed */
|
|
3277
|
+
this.disposed = false;
|
|
3278
|
+
this.syncEngine = syncEngine;
|
|
3279
|
+
this.mapName = mapName;
|
|
3280
|
+
this._query = query;
|
|
3281
|
+
this._options = options;
|
|
3282
|
+
this.subscriptionId = crypto.randomUUID();
|
|
3283
|
+
this.messageHandler = this.handleMessage.bind(this);
|
|
3284
|
+
this.syncEngine.on("message", this.messageHandler);
|
|
3285
|
+
this.sendSubscribe();
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* Handle incoming messages (both SEARCH_RESP and SEARCH_UPDATE).
|
|
3289
|
+
*/
|
|
3290
|
+
handleMessage(message) {
|
|
3291
|
+
if (message.type === "SEARCH_RESP") {
|
|
3292
|
+
this.handleSearchResponse(message);
|
|
3293
|
+
} else if (message.type === "SEARCH_UPDATE") {
|
|
3294
|
+
this.handleSearchUpdate(message);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
/**
|
|
3298
|
+
* Get the current query string.
|
|
3299
|
+
*/
|
|
3300
|
+
get query() {
|
|
3301
|
+
return this._query;
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Subscribe to result changes.
|
|
3305
|
+
* Callback is immediately called with current results.
|
|
3306
|
+
*
|
|
3307
|
+
* @param callback - Function called with updated results
|
|
3308
|
+
* @returns Unsubscribe function
|
|
3309
|
+
*/
|
|
3310
|
+
subscribe(callback) {
|
|
3311
|
+
if (this.disposed) {
|
|
3312
|
+
throw new Error("SearchHandle has been disposed");
|
|
3313
|
+
}
|
|
3314
|
+
this.listeners.add(callback);
|
|
3315
|
+
callback(this.getResults());
|
|
3316
|
+
return () => {
|
|
3317
|
+
this.listeners.delete(callback);
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* Get current results snapshot sorted by score (highest first).
|
|
3322
|
+
*
|
|
3323
|
+
* @returns Array of search results
|
|
3324
|
+
*/
|
|
3325
|
+
getResults() {
|
|
3326
|
+
return Array.from(this.results.values()).sort((a, b) => b.score - a.score);
|
|
3327
|
+
}
|
|
3328
|
+
/**
|
|
3329
|
+
* Get result count.
|
|
3330
|
+
*/
|
|
3331
|
+
get size() {
|
|
3332
|
+
return this.results.size;
|
|
3333
|
+
}
|
|
3334
|
+
/**
|
|
3335
|
+
* Update the search query.
|
|
3336
|
+
* Triggers a new subscription with the updated query.
|
|
3337
|
+
*
|
|
3338
|
+
* @param query - New query string
|
|
3339
|
+
*/
|
|
3340
|
+
setQuery(query) {
|
|
3341
|
+
if (this.disposed) {
|
|
3342
|
+
throw new Error("SearchHandle has been disposed");
|
|
3343
|
+
}
|
|
3344
|
+
if (query === this._query) {
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
this.sendUnsubscribe();
|
|
3348
|
+
this.results.clear();
|
|
3349
|
+
this._query = query;
|
|
3350
|
+
this.subscriptionId = crypto.randomUUID();
|
|
3351
|
+
this.sendSubscribe();
|
|
3352
|
+
this.notifyListeners();
|
|
3353
|
+
}
|
|
3354
|
+
/**
|
|
3355
|
+
* Update search options.
|
|
3356
|
+
*
|
|
3357
|
+
* @param options - New search options
|
|
3358
|
+
*/
|
|
3359
|
+
setOptions(options) {
|
|
3360
|
+
if (this.disposed) {
|
|
3361
|
+
throw new Error("SearchHandle has been disposed");
|
|
3362
|
+
}
|
|
3363
|
+
this.sendUnsubscribe();
|
|
3364
|
+
this.results.clear();
|
|
3365
|
+
this._options = options;
|
|
3366
|
+
this.subscriptionId = crypto.randomUUID();
|
|
3367
|
+
this.sendSubscribe();
|
|
3368
|
+
this.notifyListeners();
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Dispose of the handle and cleanup resources.
|
|
3372
|
+
* After disposal, the handle cannot be used.
|
|
3373
|
+
*/
|
|
3374
|
+
dispose() {
|
|
3375
|
+
if (this.disposed) {
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
this.disposed = true;
|
|
3379
|
+
this.sendUnsubscribe();
|
|
3380
|
+
this.syncEngine.off("message", this.messageHandler);
|
|
3381
|
+
this.results.clear();
|
|
3382
|
+
this.listeners.clear();
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Check if handle is disposed.
|
|
3386
|
+
*/
|
|
3387
|
+
isDisposed() {
|
|
3388
|
+
return this.disposed;
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Send SEARCH_SUB message to server.
|
|
3392
|
+
*/
|
|
3393
|
+
sendSubscribe() {
|
|
3394
|
+
this.syncEngine.send({
|
|
3395
|
+
type: "SEARCH_SUB",
|
|
3396
|
+
payload: {
|
|
3397
|
+
subscriptionId: this.subscriptionId,
|
|
3398
|
+
mapName: this.mapName,
|
|
3399
|
+
query: this._query,
|
|
3400
|
+
options: this._options
|
|
3401
|
+
}
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Send SEARCH_UNSUB message to server.
|
|
3406
|
+
*/
|
|
3407
|
+
sendUnsubscribe() {
|
|
3408
|
+
this.syncEngine.send({
|
|
3409
|
+
type: "SEARCH_UNSUB",
|
|
3410
|
+
payload: {
|
|
3411
|
+
subscriptionId: this.subscriptionId
|
|
3412
|
+
}
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
/**
|
|
3416
|
+
* Handle SEARCH_RESP message (initial results).
|
|
3417
|
+
*/
|
|
3418
|
+
handleSearchResponse(message) {
|
|
3419
|
+
if (message.type !== "SEARCH_RESP") return;
|
|
3420
|
+
if (message.payload?.requestId !== this.subscriptionId) return;
|
|
3421
|
+
const { results } = message.payload;
|
|
3422
|
+
if (Array.isArray(results)) {
|
|
3423
|
+
for (const result of results) {
|
|
3424
|
+
this.results.set(result.key, {
|
|
3425
|
+
key: result.key,
|
|
3426
|
+
value: result.value,
|
|
3427
|
+
score: result.score,
|
|
3428
|
+
matchedTerms: result.matchedTerms || []
|
|
3429
|
+
});
|
|
3430
|
+
}
|
|
3431
|
+
this.notifyListeners();
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
/**
|
|
3435
|
+
* Handle SEARCH_UPDATE message (delta updates).
|
|
3436
|
+
*/
|
|
3437
|
+
handleSearchUpdate(message) {
|
|
3438
|
+
if (message.type !== "SEARCH_UPDATE") return;
|
|
3439
|
+
if (message.payload?.subscriptionId !== this.subscriptionId) return;
|
|
3440
|
+
const { key, value, score, matchedTerms, type } = message.payload;
|
|
3441
|
+
switch (type) {
|
|
3442
|
+
case "ENTER":
|
|
3443
|
+
this.results.set(key, {
|
|
3444
|
+
key,
|
|
3445
|
+
value,
|
|
3446
|
+
score,
|
|
3447
|
+
matchedTerms: matchedTerms || []
|
|
3448
|
+
});
|
|
3449
|
+
break;
|
|
3450
|
+
case "UPDATE":
|
|
3451
|
+
const existing = this.results.get(key);
|
|
3452
|
+
if (existing) {
|
|
3453
|
+
existing.score = score;
|
|
3454
|
+
existing.matchedTerms = matchedTerms || [];
|
|
3455
|
+
existing.value = value;
|
|
3456
|
+
}
|
|
3457
|
+
break;
|
|
3458
|
+
case "LEAVE":
|
|
3459
|
+
this.results.delete(key);
|
|
3460
|
+
break;
|
|
3461
|
+
}
|
|
3462
|
+
this.notifyListeners();
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* Notify all listeners of result changes.
|
|
3466
|
+
*/
|
|
3467
|
+
notifyListeners() {
|
|
3468
|
+
const results = this.getResults();
|
|
3469
|
+
for (const listener of this.listeners) {
|
|
3470
|
+
try {
|
|
3471
|
+
listener(results);
|
|
3472
|
+
} catch (err) {
|
|
3473
|
+
console.error("SearchHandle listener error:", err);
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
};
|
|
3478
|
+
|
|
3479
|
+
// src/HybridQueryHandle.ts
|
|
3480
|
+
var HybridQueryHandle = class {
|
|
3481
|
+
constructor(syncEngine, mapName, filter = {}) {
|
|
3482
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
3483
|
+
this.currentResults = /* @__PURE__ */ new Map();
|
|
3484
|
+
// Change tracking
|
|
3485
|
+
this.changeTracker = new ChangeTracker();
|
|
3486
|
+
this.pendingChanges = [];
|
|
3487
|
+
this.changeListeners = /* @__PURE__ */ new Set();
|
|
3488
|
+
// Track server data reception
|
|
3489
|
+
this.hasReceivedServerData = false;
|
|
3490
|
+
this.id = crypto.randomUUID();
|
|
3491
|
+
this.syncEngine = syncEngine;
|
|
3492
|
+
this.mapName = mapName;
|
|
3493
|
+
this.filter = filter;
|
|
3494
|
+
}
|
|
3495
|
+
/**
|
|
3496
|
+
* Subscribe to query results.
|
|
3497
|
+
*/
|
|
3498
|
+
subscribe(callback) {
|
|
3499
|
+
this.listeners.add(callback);
|
|
3500
|
+
if (this.listeners.size === 1) {
|
|
3501
|
+
this.syncEngine.subscribeToHybridQuery(this);
|
|
3502
|
+
} else {
|
|
3503
|
+
callback(this.getSortedResults());
|
|
3504
|
+
}
|
|
3505
|
+
this.loadInitialLocalData().then((data) => {
|
|
3506
|
+
if (this.currentResults.size === 0) {
|
|
3507
|
+
this.onResult(data, "local");
|
|
3508
|
+
}
|
|
3509
|
+
});
|
|
3510
|
+
return () => {
|
|
3511
|
+
this.listeners.delete(callback);
|
|
3512
|
+
if (this.listeners.size === 0) {
|
|
3513
|
+
this.syncEngine.unsubscribeFromHybridQuery(this.id);
|
|
3514
|
+
}
|
|
3515
|
+
};
|
|
3516
|
+
}
|
|
3517
|
+
async loadInitialLocalData() {
|
|
3518
|
+
return this.syncEngine.runLocalHybridQuery(this.mapName, this.filter);
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* Called by SyncEngine with query results.
|
|
3522
|
+
*/
|
|
3523
|
+
onResult(items, source = "server") {
|
|
3524
|
+
logger.debug(
|
|
3525
|
+
{
|
|
3526
|
+
mapName: this.mapName,
|
|
3527
|
+
itemCount: items.length,
|
|
3528
|
+
source,
|
|
3529
|
+
currentResultsCount: this.currentResults.size,
|
|
3530
|
+
hasReceivedServerData: this.hasReceivedServerData
|
|
3531
|
+
},
|
|
3532
|
+
"HybridQueryHandle onResult"
|
|
3533
|
+
);
|
|
3534
|
+
if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
|
|
3535
|
+
logger.debug(
|
|
3536
|
+
{ mapName: this.mapName },
|
|
3537
|
+
"HybridQueryHandle ignoring empty server response"
|
|
3538
|
+
);
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
if (source === "server" && items.length > 0) {
|
|
3542
|
+
this.hasReceivedServerData = true;
|
|
3543
|
+
}
|
|
3544
|
+
const newKeys = new Set(items.map((i) => i.key));
|
|
3545
|
+
for (const key of this.currentResults.keys()) {
|
|
3546
|
+
if (!newKeys.has(key)) {
|
|
3547
|
+
this.currentResults.delete(key);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
for (const item of items) {
|
|
3551
|
+
this.currentResults.set(item.key, {
|
|
3552
|
+
value: item.value,
|
|
3553
|
+
score: item.score,
|
|
3554
|
+
matchedTerms: item.matchedTerms
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
this.computeAndNotifyChanges(Date.now());
|
|
3558
|
+
this.notify();
|
|
3559
|
+
}
|
|
3560
|
+
/**
|
|
3561
|
+
* Called by SyncEngine on live update.
|
|
3562
|
+
*/
|
|
3563
|
+
onUpdate(key, value, score, matchedTerms) {
|
|
3564
|
+
if (value === null) {
|
|
3565
|
+
this.currentResults.delete(key);
|
|
3566
|
+
} else {
|
|
3567
|
+
this.currentResults.set(key, { value, score, matchedTerms });
|
|
3568
|
+
}
|
|
3569
|
+
this.computeAndNotifyChanges(Date.now());
|
|
3570
|
+
this.notify();
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* Subscribe to change events.
|
|
3574
|
+
*/
|
|
3575
|
+
onChanges(listener) {
|
|
3576
|
+
this.changeListeners.add(listener);
|
|
3577
|
+
return () => this.changeListeners.delete(listener);
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* Get and clear pending changes.
|
|
3581
|
+
*/
|
|
3582
|
+
consumeChanges() {
|
|
3583
|
+
const changes = [...this.pendingChanges];
|
|
3584
|
+
this.pendingChanges = [];
|
|
3585
|
+
return changes;
|
|
3586
|
+
}
|
|
3587
|
+
/**
|
|
3588
|
+
* Get last change without consuming.
|
|
3589
|
+
*/
|
|
3590
|
+
getLastChange() {
|
|
3591
|
+
return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
|
|
3592
|
+
}
|
|
3593
|
+
/**
|
|
3594
|
+
* Get all pending changes without consuming.
|
|
3595
|
+
*/
|
|
3596
|
+
getPendingChanges() {
|
|
3597
|
+
return [...this.pendingChanges];
|
|
3598
|
+
}
|
|
3599
|
+
/**
|
|
3600
|
+
* Clear all pending changes.
|
|
3601
|
+
*/
|
|
3602
|
+
clearChanges() {
|
|
3603
|
+
this.pendingChanges = [];
|
|
3604
|
+
}
|
|
3605
|
+
/**
|
|
3606
|
+
* Reset change tracker.
|
|
3607
|
+
*/
|
|
3608
|
+
resetChangeTracker() {
|
|
3609
|
+
this.changeTracker.reset();
|
|
3610
|
+
this.pendingChanges = [];
|
|
3611
|
+
}
|
|
3612
|
+
computeAndNotifyChanges(timestamp) {
|
|
3613
|
+
const dataMap = /* @__PURE__ */ new Map();
|
|
3614
|
+
for (const [key, entry] of this.currentResults) {
|
|
3615
|
+
dataMap.set(key, entry.value);
|
|
3616
|
+
}
|
|
3617
|
+
const changes = this.changeTracker.computeChanges(dataMap, timestamp);
|
|
3618
|
+
if (changes.length > 0) {
|
|
3619
|
+
this.pendingChanges.push(...changes);
|
|
3620
|
+
this.notifyChangeListeners(changes);
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
notifyChangeListeners(changes) {
|
|
3624
|
+
for (const listener of this.changeListeners) {
|
|
3625
|
+
try {
|
|
3626
|
+
listener(changes);
|
|
3627
|
+
} catch (e) {
|
|
3628
|
+
logger.error({ err: e }, "HybridQueryHandle change listener error");
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
notify() {
|
|
3633
|
+
const results = this.getSortedResults();
|
|
3634
|
+
for (const listener of this.listeners) {
|
|
3635
|
+
listener(results);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
/**
|
|
3639
|
+
* Get sorted results with _key and _score.
|
|
3640
|
+
*/
|
|
3641
|
+
getSortedResults() {
|
|
3642
|
+
const results = Array.from(this.currentResults.entries()).map(
|
|
3643
|
+
([key, entry]) => ({
|
|
3644
|
+
value: entry.value,
|
|
3645
|
+
_key: key,
|
|
3646
|
+
_score: entry.score,
|
|
3647
|
+
_matchedTerms: entry.matchedTerms
|
|
3648
|
+
})
|
|
3649
|
+
);
|
|
3650
|
+
if (this.filter.sort) {
|
|
3651
|
+
results.sort((a, b) => {
|
|
3652
|
+
for (const [field, direction] of Object.entries(this.filter.sort)) {
|
|
3653
|
+
let valA;
|
|
3654
|
+
let valB;
|
|
3655
|
+
if (field === "_score") {
|
|
3656
|
+
valA = a._score ?? 0;
|
|
3657
|
+
valB = b._score ?? 0;
|
|
3658
|
+
} else if (field === "_key") {
|
|
3659
|
+
valA = a._key;
|
|
3660
|
+
valB = b._key;
|
|
3661
|
+
} else {
|
|
3662
|
+
valA = a.value[field];
|
|
3663
|
+
valB = b.value[field];
|
|
3664
|
+
}
|
|
3665
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
3666
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
3667
|
+
}
|
|
3668
|
+
return 0;
|
|
3669
|
+
});
|
|
3670
|
+
}
|
|
3671
|
+
let sliced = results;
|
|
3672
|
+
if (this.filter.offset) {
|
|
3673
|
+
sliced = sliced.slice(this.filter.offset);
|
|
3674
|
+
}
|
|
3675
|
+
if (this.filter.limit) {
|
|
3676
|
+
sliced = sliced.slice(0, this.filter.limit);
|
|
3677
|
+
}
|
|
3678
|
+
return sliced;
|
|
3679
|
+
}
|
|
3680
|
+
/**
|
|
3681
|
+
* Get the filter configuration.
|
|
3682
|
+
*/
|
|
3683
|
+
getFilter() {
|
|
3684
|
+
return this.filter;
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* Get the map name.
|
|
3688
|
+
*/
|
|
3689
|
+
getMapName() {
|
|
3690
|
+
return this.mapName;
|
|
3691
|
+
}
|
|
3692
|
+
/**
|
|
3693
|
+
* Check if this query contains FTS predicates.
|
|
3694
|
+
*/
|
|
3695
|
+
hasFTSPredicate() {
|
|
3696
|
+
return this.filter.predicate ? this.containsFTS(this.filter.predicate) : false;
|
|
3697
|
+
}
|
|
3698
|
+
containsFTS(predicate) {
|
|
3699
|
+
if (predicate.op === "match" || predicate.op === "matchPhrase" || predicate.op === "matchPrefix") {
|
|
3700
|
+
return true;
|
|
3701
|
+
}
|
|
3702
|
+
if (predicate.children) {
|
|
3703
|
+
return predicate.children.some((child) => this.containsFTS(child));
|
|
3704
|
+
}
|
|
3705
|
+
return false;
|
|
3706
|
+
}
|
|
3707
|
+
};
|
|
3708
|
+
|
|
3038
3709
|
// src/cluster/ClusterClient.ts
|
|
3039
3710
|
import {
|
|
3040
3711
|
DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
|
|
@@ -4859,6 +5530,113 @@ var TopGunClient = class {
|
|
|
4859
5530
|
return this.syncEngine.onBackpressure(event, listener);
|
|
4860
5531
|
}
|
|
4861
5532
|
// ============================================
|
|
5533
|
+
// Full-Text Search API (Phase 11.1a)
|
|
5534
|
+
// ============================================
|
|
5535
|
+
/**
|
|
5536
|
+
* Perform a one-shot BM25 search on the server.
|
|
5537
|
+
*
|
|
5538
|
+
* Searches the specified map using BM25 ranking algorithm.
|
|
5539
|
+
* Requires FTS to be enabled for the map on the server.
|
|
5540
|
+
*
|
|
5541
|
+
* @param mapName Name of the map to search
|
|
5542
|
+
* @param query Search query text
|
|
5543
|
+
* @param options Search options
|
|
5544
|
+
* @returns Promise resolving to search results sorted by relevance
|
|
5545
|
+
*
|
|
5546
|
+
* @example
|
|
5547
|
+
* ```typescript
|
|
5548
|
+
* const results = await client.search<Article>('articles', 'machine learning', {
|
|
5549
|
+
* limit: 20,
|
|
5550
|
+
* minScore: 0.5,
|
|
5551
|
+
* boost: { title: 2.0, body: 1.0 }
|
|
5552
|
+
* });
|
|
5553
|
+
*
|
|
5554
|
+
* for (const result of results) {
|
|
5555
|
+
* console.log(`${result.key}: ${result.value.title} (score: ${result.score})`);
|
|
5556
|
+
* }
|
|
5557
|
+
* ```
|
|
5558
|
+
*/
|
|
5559
|
+
async search(mapName, query, options) {
|
|
5560
|
+
return this.syncEngine.search(mapName, query, options);
|
|
5561
|
+
}
|
|
5562
|
+
// ============================================
|
|
5563
|
+
// Live Search API (Phase 11.1b)
|
|
5564
|
+
// ============================================
|
|
5565
|
+
/**
|
|
5566
|
+
* Subscribe to live search results with real-time updates.
|
|
5567
|
+
*
|
|
5568
|
+
* Unlike the one-shot `search()` method, `searchSubscribe()` returns a handle
|
|
5569
|
+
* that receives delta updates (ENTER/UPDATE/LEAVE) when documents change.
|
|
5570
|
+
* This is ideal for live search UIs that need to reflect data changes.
|
|
5571
|
+
*
|
|
5572
|
+
* @param mapName Name of the map to search
|
|
5573
|
+
* @param query Search query text
|
|
5574
|
+
* @param options Search options (limit, minScore, boost)
|
|
5575
|
+
* @returns SearchHandle for managing the subscription
|
|
5576
|
+
*
|
|
5577
|
+
* @example
|
|
5578
|
+
* ```typescript
|
|
5579
|
+
* const handle = client.searchSubscribe<Article>('articles', 'machine learning', {
|
|
5580
|
+
* limit: 20,
|
|
5581
|
+
* minScore: 0.5
|
|
5582
|
+
* });
|
|
5583
|
+
*
|
|
5584
|
+
* // Subscribe to result changes
|
|
5585
|
+
* const unsubscribe = handle.subscribe((results) => {
|
|
5586
|
+
* setSearchResults(results);
|
|
5587
|
+
* });
|
|
5588
|
+
*
|
|
5589
|
+
* // Update query dynamically
|
|
5590
|
+
* handle.setQuery('deep learning');
|
|
5591
|
+
*
|
|
5592
|
+
* // Get current snapshot
|
|
5593
|
+
* const snapshot = handle.getResults();
|
|
5594
|
+
*
|
|
5595
|
+
* // Cleanup when done
|
|
5596
|
+
* handle.dispose();
|
|
5597
|
+
* ```
|
|
5598
|
+
*/
|
|
5599
|
+
searchSubscribe(mapName, query, options) {
|
|
5600
|
+
return new SearchHandle(this.syncEngine, mapName, query, options);
|
|
5601
|
+
}
|
|
5602
|
+
// ============================================
|
|
5603
|
+
// Hybrid Query API (Phase 12)
|
|
5604
|
+
// ============================================
|
|
5605
|
+
/**
|
|
5606
|
+
* Create a hybrid query combining FTS with traditional filters.
|
|
5607
|
+
*
|
|
5608
|
+
* Hybrid queries allow combining full-text search predicates (match, matchPhrase, matchPrefix)
|
|
5609
|
+
* with traditional filter predicates (eq, gt, lt, contains, etc.) in a single query.
|
|
5610
|
+
* Results include relevance scores for FTS ranking.
|
|
5611
|
+
*
|
|
5612
|
+
* @param mapName Name of the map to query
|
|
5613
|
+
* @param filter Hybrid query filter with predicate, where, sort, limit, offset
|
|
5614
|
+
* @returns HybridQueryHandle for managing the subscription
|
|
5615
|
+
*
|
|
5616
|
+
* @example
|
|
5617
|
+
* ```typescript
|
|
5618
|
+
* import { Predicates } from '@topgunbuild/core';
|
|
5619
|
+
*
|
|
5620
|
+
* // Hybrid query: FTS + filter
|
|
5621
|
+
* const handle = client.hybridQuery<Article>('articles', {
|
|
5622
|
+
* predicate: Predicates.and(
|
|
5623
|
+
* Predicates.match('body', 'machine learning'),
|
|
5624
|
+
* Predicates.equal('category', 'tech')
|
|
5625
|
+
* ),
|
|
5626
|
+
* sort: { _score: 'desc' },
|
|
5627
|
+
* limit: 20
|
|
5628
|
+
* });
|
|
5629
|
+
*
|
|
5630
|
+
* // Subscribe to results
|
|
5631
|
+
* handle.subscribe((results) => {
|
|
5632
|
+
* results.forEach(r => console.log(`${r._key}: score=${r._score}`));
|
|
5633
|
+
* });
|
|
5634
|
+
* ```
|
|
5635
|
+
*/
|
|
5636
|
+
hybridQuery(mapName, filter = {}) {
|
|
5637
|
+
return new HybridQueryHandle(this.syncEngine, mapName, filter);
|
|
5638
|
+
}
|
|
5639
|
+
// ============================================
|
|
4862
5640
|
// Entry Processor API (Phase 5.03)
|
|
4863
5641
|
// ============================================
|
|
4864
5642
|
/**
|
|
@@ -5465,12 +6243,14 @@ export {
|
|
|
5465
6243
|
DEFAULT_CLUSTER_CONFIG,
|
|
5466
6244
|
EncryptedStorageAdapter,
|
|
5467
6245
|
EventJournalReader,
|
|
6246
|
+
HybridQueryHandle,
|
|
5468
6247
|
IDBAdapter,
|
|
5469
6248
|
LWWMap3 as LWWMap,
|
|
5470
6249
|
PNCounterHandle,
|
|
5471
6250
|
PartitionRouter,
|
|
5472
6251
|
Predicates,
|
|
5473
6252
|
QueryHandle,
|
|
6253
|
+
SearchHandle,
|
|
5474
6254
|
SingleServerProvider,
|
|
5475
6255
|
SyncEngine,
|
|
5476
6256
|
SyncState,
|