@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.js CHANGED
@@ -39,12 +39,14 @@ __export(index_exports, {
39
39
  DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
40
40
  EncryptedStorageAdapter: () => EncryptedStorageAdapter,
41
41
  EventJournalReader: () => EventJournalReader,
42
+ HybridQueryHandle: () => HybridQueryHandle,
42
43
  IDBAdapter: () => IDBAdapter,
43
44
  LWWMap: () => import_core9.LWWMap,
44
45
  PNCounterHandle: () => PNCounterHandle,
45
46
  PartitionRouter: () => PartitionRouter,
46
47
  Predicates: () => import_core9.Predicates,
47
48
  QueryHandle: () => QueryHandle,
49
+ SearchHandle: () => SearchHandle,
48
50
  SingleServerProvider: () => SingleServerProvider,
49
51
  SyncEngine: () => SyncEngine,
50
52
  SyncState: () => SyncState,
@@ -769,6 +771,16 @@ var _SyncEngine = class _SyncEngine {
769
771
  // ============================================
770
772
  /** Message listeners for journal and other generic messages */
771
773
  this.messageListeners = /* @__PURE__ */ new Set();
774
+ // ============================================
775
+ // Full-Text Search Methods (Phase 11.1a)
776
+ // ============================================
777
+ /** Pending search requests by requestId */
778
+ this.pendingSearchRequests = /* @__PURE__ */ new Map();
779
+ // ============================================
780
+ // Hybrid Query Support (Phase 12)
781
+ // ============================================
782
+ /** Active hybrid query subscriptions */
783
+ this.hybridQueries = /* @__PURE__ */ new Map();
772
784
  if (!config.serverUrl && !config.connectionProvider) {
773
785
  throw new Error("SyncEngine requires either serverUrl or connectionProvider");
774
786
  }
@@ -1654,6 +1666,21 @@ var _SyncEngine = class _SyncEngine {
1654
1666
  this.conflictResolverClient.handleMergeRejected(message);
1655
1667
  break;
1656
1668
  }
1669
+ // ============ Full-Text Search Message Handlers (Phase 11.1a) ============
1670
+ case "SEARCH_RESP": {
1671
+ logger.debug({ requestId: message.payload?.requestId, resultCount: message.payload?.results?.length }, "Received SEARCH_RESP");
1672
+ this.handleSearchResponse(message.payload);
1673
+ break;
1674
+ }
1675
+ // ============ Live Search Message Handlers (Phase 11.1b) ============
1676
+ case "SEARCH_UPDATE": {
1677
+ logger.debug({
1678
+ subscriptionId: message.payload?.subscriptionId,
1679
+ key: message.payload?.key,
1680
+ type: message.payload?.type
1681
+ }, "Received SEARCH_UPDATE");
1682
+ break;
1683
+ }
1657
1684
  }
1658
1685
  if (message.timestamp) {
1659
1686
  this.hlc.update(message.timestamp);
@@ -2417,6 +2444,65 @@ var _SyncEngine = class _SyncEngine {
2417
2444
  }
2418
2445
  }
2419
2446
  }
2447
+ /**
2448
+ * Perform a one-shot BM25 search on the server.
2449
+ *
2450
+ * @param mapName Name of the map to search
2451
+ * @param query Search query text
2452
+ * @param options Search options (limit, minScore, boost)
2453
+ * @returns Promise resolving to search results
2454
+ */
2455
+ async search(mapName, query, options) {
2456
+ if (!this.isAuthenticated()) {
2457
+ throw new Error("Not connected to server");
2458
+ }
2459
+ const requestId = crypto.randomUUID();
2460
+ return new Promise((resolve, reject) => {
2461
+ const timeout = setTimeout(() => {
2462
+ this.pendingSearchRequests.delete(requestId);
2463
+ reject(new Error("Search request timed out"));
2464
+ }, _SyncEngine.SEARCH_TIMEOUT);
2465
+ this.pendingSearchRequests.set(requestId, {
2466
+ resolve: (results) => {
2467
+ clearTimeout(timeout);
2468
+ resolve(results);
2469
+ },
2470
+ reject: (error) => {
2471
+ clearTimeout(timeout);
2472
+ reject(error);
2473
+ },
2474
+ timeout
2475
+ });
2476
+ const sent = this.sendMessage({
2477
+ type: "SEARCH",
2478
+ payload: {
2479
+ requestId,
2480
+ mapName,
2481
+ query,
2482
+ options
2483
+ }
2484
+ });
2485
+ if (!sent) {
2486
+ this.pendingSearchRequests.delete(requestId);
2487
+ clearTimeout(timeout);
2488
+ reject(new Error("Failed to send search request"));
2489
+ }
2490
+ });
2491
+ }
2492
+ /**
2493
+ * Handle search response from server.
2494
+ */
2495
+ handleSearchResponse(payload) {
2496
+ const pending = this.pendingSearchRequests.get(payload.requestId);
2497
+ if (pending) {
2498
+ this.pendingSearchRequests.delete(payload.requestId);
2499
+ if (payload.error) {
2500
+ pending.reject(new Error(payload.error));
2501
+ } else {
2502
+ pending.resolve(payload.results);
2503
+ }
2504
+ }
2505
+ }
2420
2506
  // ============================================
2421
2507
  // Conflict Resolver Client (Phase 5.05)
2422
2508
  // ============================================
@@ -2427,9 +2513,156 @@ var _SyncEngine = class _SyncEngine {
2427
2513
  getConflictResolverClient() {
2428
2514
  return this.conflictResolverClient;
2429
2515
  }
2516
+ /**
2517
+ * Subscribe to a hybrid query (FTS + filter combination).
2518
+ */
2519
+ subscribeToHybridQuery(query) {
2520
+ this.hybridQueries.set(query.id, query);
2521
+ const filter = query.getFilter();
2522
+ const mapName = query.getMapName();
2523
+ if (query.hasFTSPredicate() && this.stateMachine.getState() === "CONNECTED" /* CONNECTED */) {
2524
+ this.sendHybridQuerySubscription(query.id, mapName, filter);
2525
+ }
2526
+ this.runLocalHybridQuery(mapName, filter).then((results) => {
2527
+ query.onResult(results, "local");
2528
+ });
2529
+ }
2530
+ /**
2531
+ * Unsubscribe from a hybrid query.
2532
+ */
2533
+ unsubscribeFromHybridQuery(queryId) {
2534
+ const query = this.hybridQueries.get(queryId);
2535
+ if (query) {
2536
+ this.hybridQueries.delete(queryId);
2537
+ if (this.stateMachine.getState() === "CONNECTED" /* CONNECTED */ && query.hasFTSPredicate()) {
2538
+ this.sendMessage({
2539
+ type: "HYBRID_QUERY_UNSUBSCRIBE",
2540
+ payload: { subscriptionId: queryId }
2541
+ });
2542
+ }
2543
+ }
2544
+ }
2545
+ /**
2546
+ * Send hybrid query subscription to server.
2547
+ */
2548
+ sendHybridQuerySubscription(queryId, mapName, filter) {
2549
+ this.sendMessage({
2550
+ type: "HYBRID_QUERY_SUBSCRIBE",
2551
+ payload: {
2552
+ subscriptionId: queryId,
2553
+ mapName,
2554
+ predicate: filter.predicate,
2555
+ where: filter.where,
2556
+ sort: filter.sort,
2557
+ limit: filter.limit,
2558
+ offset: filter.offset
2559
+ }
2560
+ });
2561
+ }
2562
+ /**
2563
+ * Run a local hybrid query (FTS + filter combination).
2564
+ * For FTS predicates, returns results with score = 0 (local-only mode).
2565
+ * Server provides actual FTS scoring.
2566
+ */
2567
+ async runLocalHybridQuery(mapName, filter) {
2568
+ if (!this.storageAdapter) {
2569
+ return [];
2570
+ }
2571
+ const results = [];
2572
+ const allKeys = await this.storageAdapter.getAllKeys();
2573
+ const mapPrefix = `${mapName}:`;
2574
+ const entries = [];
2575
+ for (const fullKey of allKeys) {
2576
+ if (fullKey.startsWith(mapPrefix)) {
2577
+ const key = fullKey.substring(mapPrefix.length);
2578
+ const record = await this.storageAdapter.get(fullKey);
2579
+ if (record) {
2580
+ entries.push([key, record]);
2581
+ }
2582
+ }
2583
+ }
2584
+ for (const [key, record] of entries) {
2585
+ if (record === null || record.value === null) continue;
2586
+ const value = record.value;
2587
+ if (filter.predicate) {
2588
+ const matches = (0, import_core.evaluatePredicate)(filter.predicate, value);
2589
+ if (!matches) continue;
2590
+ }
2591
+ if (filter.where) {
2592
+ let whereMatches = true;
2593
+ for (const [field, expected] of Object.entries(filter.where)) {
2594
+ if (value[field] !== expected) {
2595
+ whereMatches = false;
2596
+ break;
2597
+ }
2598
+ }
2599
+ if (!whereMatches) continue;
2600
+ }
2601
+ results.push({
2602
+ key,
2603
+ value,
2604
+ score: 0,
2605
+ // Local doesn't have FTS scoring
2606
+ matchedTerms: []
2607
+ });
2608
+ }
2609
+ if (filter.sort) {
2610
+ results.sort((a, b) => {
2611
+ for (const [field, direction] of Object.entries(filter.sort)) {
2612
+ let valA;
2613
+ let valB;
2614
+ if (field === "_score") {
2615
+ valA = a.score ?? 0;
2616
+ valB = b.score ?? 0;
2617
+ } else if (field === "_key") {
2618
+ valA = a.key;
2619
+ valB = b.key;
2620
+ } else {
2621
+ valA = a.value[field];
2622
+ valB = b.value[field];
2623
+ }
2624
+ if (valA < valB) return direction === "asc" ? -1 : 1;
2625
+ if (valA > valB) return direction === "asc" ? 1 : -1;
2626
+ }
2627
+ return 0;
2628
+ });
2629
+ }
2630
+ let sliced = results;
2631
+ if (filter.offset) {
2632
+ sliced = sliced.slice(filter.offset);
2633
+ }
2634
+ if (filter.limit) {
2635
+ sliced = sliced.slice(0, filter.limit);
2636
+ }
2637
+ return sliced;
2638
+ }
2639
+ /**
2640
+ * Handle hybrid query response from server.
2641
+ */
2642
+ handleHybridQueryResponse(payload) {
2643
+ const query = this.hybridQueries.get(payload.subscriptionId);
2644
+ if (query) {
2645
+ query.onResult(payload.results, "server");
2646
+ }
2647
+ }
2648
+ /**
2649
+ * Handle hybrid query delta update from server.
2650
+ */
2651
+ handleHybridQueryDelta(payload) {
2652
+ const query = this.hybridQueries.get(payload.subscriptionId);
2653
+ if (query) {
2654
+ if (payload.type === "LEAVE") {
2655
+ query.onUpdate(payload.key, null);
2656
+ } else {
2657
+ query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
2658
+ }
2659
+ }
2660
+ }
2430
2661
  };
2431
2662
  /** Default timeout for entry processor requests (ms) */
2432
2663
  _SyncEngine.PROCESSOR_TIMEOUT = 3e4;
2664
+ /** Default timeout for search requests (ms) */
2665
+ _SyncEngine.SEARCH_TIMEOUT = 3e4;
2433
2666
  var SyncEngine = _SyncEngine;
2434
2667
 
2435
2668
  // src/TopGunClient.ts
@@ -3095,6 +3328,446 @@ var EventJournalReader = class {
3095
3328
  }
3096
3329
  };
3097
3330
 
3331
+ // src/SearchHandle.ts
3332
+ var SearchHandle = class {
3333
+ constructor(syncEngine, mapName, query, options) {
3334
+ /** Current results map (key → result) */
3335
+ this.results = /* @__PURE__ */ new Map();
3336
+ /** Result change listeners */
3337
+ this.listeners = /* @__PURE__ */ new Set();
3338
+ /** Whether the handle has been disposed */
3339
+ this.disposed = false;
3340
+ this.syncEngine = syncEngine;
3341
+ this.mapName = mapName;
3342
+ this._query = query;
3343
+ this._options = options;
3344
+ this.subscriptionId = crypto.randomUUID();
3345
+ this.messageHandler = this.handleMessage.bind(this);
3346
+ this.syncEngine.on("message", this.messageHandler);
3347
+ this.sendSubscribe();
3348
+ }
3349
+ /**
3350
+ * Handle incoming messages (both SEARCH_RESP and SEARCH_UPDATE).
3351
+ */
3352
+ handleMessage(message) {
3353
+ if (message.type === "SEARCH_RESP") {
3354
+ this.handleSearchResponse(message);
3355
+ } else if (message.type === "SEARCH_UPDATE") {
3356
+ this.handleSearchUpdate(message);
3357
+ }
3358
+ }
3359
+ /**
3360
+ * Get the current query string.
3361
+ */
3362
+ get query() {
3363
+ return this._query;
3364
+ }
3365
+ /**
3366
+ * Subscribe to result changes.
3367
+ * Callback is immediately called with current results.
3368
+ *
3369
+ * @param callback - Function called with updated results
3370
+ * @returns Unsubscribe function
3371
+ */
3372
+ subscribe(callback) {
3373
+ if (this.disposed) {
3374
+ throw new Error("SearchHandle has been disposed");
3375
+ }
3376
+ this.listeners.add(callback);
3377
+ callback(this.getResults());
3378
+ return () => {
3379
+ this.listeners.delete(callback);
3380
+ };
3381
+ }
3382
+ /**
3383
+ * Get current results snapshot sorted by score (highest first).
3384
+ *
3385
+ * @returns Array of search results
3386
+ */
3387
+ getResults() {
3388
+ return Array.from(this.results.values()).sort((a, b) => b.score - a.score);
3389
+ }
3390
+ /**
3391
+ * Get result count.
3392
+ */
3393
+ get size() {
3394
+ return this.results.size;
3395
+ }
3396
+ /**
3397
+ * Update the search query.
3398
+ * Triggers a new subscription with the updated query.
3399
+ *
3400
+ * @param query - New query string
3401
+ */
3402
+ setQuery(query) {
3403
+ if (this.disposed) {
3404
+ throw new Error("SearchHandle has been disposed");
3405
+ }
3406
+ if (query === this._query) {
3407
+ return;
3408
+ }
3409
+ this.sendUnsubscribe();
3410
+ this.results.clear();
3411
+ this._query = query;
3412
+ this.subscriptionId = crypto.randomUUID();
3413
+ this.sendSubscribe();
3414
+ this.notifyListeners();
3415
+ }
3416
+ /**
3417
+ * Update search options.
3418
+ *
3419
+ * @param options - New search options
3420
+ */
3421
+ setOptions(options) {
3422
+ if (this.disposed) {
3423
+ throw new Error("SearchHandle has been disposed");
3424
+ }
3425
+ this.sendUnsubscribe();
3426
+ this.results.clear();
3427
+ this._options = options;
3428
+ this.subscriptionId = crypto.randomUUID();
3429
+ this.sendSubscribe();
3430
+ this.notifyListeners();
3431
+ }
3432
+ /**
3433
+ * Dispose of the handle and cleanup resources.
3434
+ * After disposal, the handle cannot be used.
3435
+ */
3436
+ dispose() {
3437
+ if (this.disposed) {
3438
+ return;
3439
+ }
3440
+ this.disposed = true;
3441
+ this.sendUnsubscribe();
3442
+ this.syncEngine.off("message", this.messageHandler);
3443
+ this.results.clear();
3444
+ this.listeners.clear();
3445
+ }
3446
+ /**
3447
+ * Check if handle is disposed.
3448
+ */
3449
+ isDisposed() {
3450
+ return this.disposed;
3451
+ }
3452
+ /**
3453
+ * Send SEARCH_SUB message to server.
3454
+ */
3455
+ sendSubscribe() {
3456
+ this.syncEngine.send({
3457
+ type: "SEARCH_SUB",
3458
+ payload: {
3459
+ subscriptionId: this.subscriptionId,
3460
+ mapName: this.mapName,
3461
+ query: this._query,
3462
+ options: this._options
3463
+ }
3464
+ });
3465
+ }
3466
+ /**
3467
+ * Send SEARCH_UNSUB message to server.
3468
+ */
3469
+ sendUnsubscribe() {
3470
+ this.syncEngine.send({
3471
+ type: "SEARCH_UNSUB",
3472
+ payload: {
3473
+ subscriptionId: this.subscriptionId
3474
+ }
3475
+ });
3476
+ }
3477
+ /**
3478
+ * Handle SEARCH_RESP message (initial results).
3479
+ */
3480
+ handleSearchResponse(message) {
3481
+ if (message.type !== "SEARCH_RESP") return;
3482
+ if (message.payload?.requestId !== this.subscriptionId) return;
3483
+ const { results } = message.payload;
3484
+ if (Array.isArray(results)) {
3485
+ for (const result of results) {
3486
+ this.results.set(result.key, {
3487
+ key: result.key,
3488
+ value: result.value,
3489
+ score: result.score,
3490
+ matchedTerms: result.matchedTerms || []
3491
+ });
3492
+ }
3493
+ this.notifyListeners();
3494
+ }
3495
+ }
3496
+ /**
3497
+ * Handle SEARCH_UPDATE message (delta updates).
3498
+ */
3499
+ handleSearchUpdate(message) {
3500
+ if (message.type !== "SEARCH_UPDATE") return;
3501
+ if (message.payload?.subscriptionId !== this.subscriptionId) return;
3502
+ const { key, value, score, matchedTerms, type } = message.payload;
3503
+ switch (type) {
3504
+ case "ENTER":
3505
+ this.results.set(key, {
3506
+ key,
3507
+ value,
3508
+ score,
3509
+ matchedTerms: matchedTerms || []
3510
+ });
3511
+ break;
3512
+ case "UPDATE":
3513
+ const existing = this.results.get(key);
3514
+ if (existing) {
3515
+ existing.score = score;
3516
+ existing.matchedTerms = matchedTerms || [];
3517
+ existing.value = value;
3518
+ }
3519
+ break;
3520
+ case "LEAVE":
3521
+ this.results.delete(key);
3522
+ break;
3523
+ }
3524
+ this.notifyListeners();
3525
+ }
3526
+ /**
3527
+ * Notify all listeners of result changes.
3528
+ */
3529
+ notifyListeners() {
3530
+ const results = this.getResults();
3531
+ for (const listener of this.listeners) {
3532
+ try {
3533
+ listener(results);
3534
+ } catch (err) {
3535
+ console.error("SearchHandle listener error:", err);
3536
+ }
3537
+ }
3538
+ }
3539
+ };
3540
+
3541
+ // src/HybridQueryHandle.ts
3542
+ var HybridQueryHandle = class {
3543
+ constructor(syncEngine, mapName, filter = {}) {
3544
+ this.listeners = /* @__PURE__ */ new Set();
3545
+ this.currentResults = /* @__PURE__ */ new Map();
3546
+ // Change tracking
3547
+ this.changeTracker = new ChangeTracker();
3548
+ this.pendingChanges = [];
3549
+ this.changeListeners = /* @__PURE__ */ new Set();
3550
+ // Track server data reception
3551
+ this.hasReceivedServerData = false;
3552
+ this.id = crypto.randomUUID();
3553
+ this.syncEngine = syncEngine;
3554
+ this.mapName = mapName;
3555
+ this.filter = filter;
3556
+ }
3557
+ /**
3558
+ * Subscribe to query results.
3559
+ */
3560
+ subscribe(callback) {
3561
+ this.listeners.add(callback);
3562
+ if (this.listeners.size === 1) {
3563
+ this.syncEngine.subscribeToHybridQuery(this);
3564
+ } else {
3565
+ callback(this.getSortedResults());
3566
+ }
3567
+ this.loadInitialLocalData().then((data) => {
3568
+ if (this.currentResults.size === 0) {
3569
+ this.onResult(data, "local");
3570
+ }
3571
+ });
3572
+ return () => {
3573
+ this.listeners.delete(callback);
3574
+ if (this.listeners.size === 0) {
3575
+ this.syncEngine.unsubscribeFromHybridQuery(this.id);
3576
+ }
3577
+ };
3578
+ }
3579
+ async loadInitialLocalData() {
3580
+ return this.syncEngine.runLocalHybridQuery(this.mapName, this.filter);
3581
+ }
3582
+ /**
3583
+ * Called by SyncEngine with query results.
3584
+ */
3585
+ onResult(items, source = "server") {
3586
+ logger.debug(
3587
+ {
3588
+ mapName: this.mapName,
3589
+ itemCount: items.length,
3590
+ source,
3591
+ currentResultsCount: this.currentResults.size,
3592
+ hasReceivedServerData: this.hasReceivedServerData
3593
+ },
3594
+ "HybridQueryHandle onResult"
3595
+ );
3596
+ if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
3597
+ logger.debug(
3598
+ { mapName: this.mapName },
3599
+ "HybridQueryHandle ignoring empty server response"
3600
+ );
3601
+ return;
3602
+ }
3603
+ if (source === "server" && items.length > 0) {
3604
+ this.hasReceivedServerData = true;
3605
+ }
3606
+ const newKeys = new Set(items.map((i) => i.key));
3607
+ for (const key of this.currentResults.keys()) {
3608
+ if (!newKeys.has(key)) {
3609
+ this.currentResults.delete(key);
3610
+ }
3611
+ }
3612
+ for (const item of items) {
3613
+ this.currentResults.set(item.key, {
3614
+ value: item.value,
3615
+ score: item.score,
3616
+ matchedTerms: item.matchedTerms
3617
+ });
3618
+ }
3619
+ this.computeAndNotifyChanges(Date.now());
3620
+ this.notify();
3621
+ }
3622
+ /**
3623
+ * Called by SyncEngine on live update.
3624
+ */
3625
+ onUpdate(key, value, score, matchedTerms) {
3626
+ if (value === null) {
3627
+ this.currentResults.delete(key);
3628
+ } else {
3629
+ this.currentResults.set(key, { value, score, matchedTerms });
3630
+ }
3631
+ this.computeAndNotifyChanges(Date.now());
3632
+ this.notify();
3633
+ }
3634
+ /**
3635
+ * Subscribe to change events.
3636
+ */
3637
+ onChanges(listener) {
3638
+ this.changeListeners.add(listener);
3639
+ return () => this.changeListeners.delete(listener);
3640
+ }
3641
+ /**
3642
+ * Get and clear pending changes.
3643
+ */
3644
+ consumeChanges() {
3645
+ const changes = [...this.pendingChanges];
3646
+ this.pendingChanges = [];
3647
+ return changes;
3648
+ }
3649
+ /**
3650
+ * Get last change without consuming.
3651
+ */
3652
+ getLastChange() {
3653
+ return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
3654
+ }
3655
+ /**
3656
+ * Get all pending changes without consuming.
3657
+ */
3658
+ getPendingChanges() {
3659
+ return [...this.pendingChanges];
3660
+ }
3661
+ /**
3662
+ * Clear all pending changes.
3663
+ */
3664
+ clearChanges() {
3665
+ this.pendingChanges = [];
3666
+ }
3667
+ /**
3668
+ * Reset change tracker.
3669
+ */
3670
+ resetChangeTracker() {
3671
+ this.changeTracker.reset();
3672
+ this.pendingChanges = [];
3673
+ }
3674
+ computeAndNotifyChanges(timestamp) {
3675
+ const dataMap = /* @__PURE__ */ new Map();
3676
+ for (const [key, entry] of this.currentResults) {
3677
+ dataMap.set(key, entry.value);
3678
+ }
3679
+ const changes = this.changeTracker.computeChanges(dataMap, timestamp);
3680
+ if (changes.length > 0) {
3681
+ this.pendingChanges.push(...changes);
3682
+ this.notifyChangeListeners(changes);
3683
+ }
3684
+ }
3685
+ notifyChangeListeners(changes) {
3686
+ for (const listener of this.changeListeners) {
3687
+ try {
3688
+ listener(changes);
3689
+ } catch (e) {
3690
+ logger.error({ err: e }, "HybridQueryHandle change listener error");
3691
+ }
3692
+ }
3693
+ }
3694
+ notify() {
3695
+ const results = this.getSortedResults();
3696
+ for (const listener of this.listeners) {
3697
+ listener(results);
3698
+ }
3699
+ }
3700
+ /**
3701
+ * Get sorted results with _key and _score.
3702
+ */
3703
+ getSortedResults() {
3704
+ const results = Array.from(this.currentResults.entries()).map(
3705
+ ([key, entry]) => ({
3706
+ value: entry.value,
3707
+ _key: key,
3708
+ _score: entry.score,
3709
+ _matchedTerms: entry.matchedTerms
3710
+ })
3711
+ );
3712
+ if (this.filter.sort) {
3713
+ results.sort((a, b) => {
3714
+ for (const [field, direction] of Object.entries(this.filter.sort)) {
3715
+ let valA;
3716
+ let valB;
3717
+ if (field === "_score") {
3718
+ valA = a._score ?? 0;
3719
+ valB = b._score ?? 0;
3720
+ } else if (field === "_key") {
3721
+ valA = a._key;
3722
+ valB = b._key;
3723
+ } else {
3724
+ valA = a.value[field];
3725
+ valB = b.value[field];
3726
+ }
3727
+ if (valA < valB) return direction === "asc" ? -1 : 1;
3728
+ if (valA > valB) return direction === "asc" ? 1 : -1;
3729
+ }
3730
+ return 0;
3731
+ });
3732
+ }
3733
+ let sliced = results;
3734
+ if (this.filter.offset) {
3735
+ sliced = sliced.slice(this.filter.offset);
3736
+ }
3737
+ if (this.filter.limit) {
3738
+ sliced = sliced.slice(0, this.filter.limit);
3739
+ }
3740
+ return sliced;
3741
+ }
3742
+ /**
3743
+ * Get the filter configuration.
3744
+ */
3745
+ getFilter() {
3746
+ return this.filter;
3747
+ }
3748
+ /**
3749
+ * Get the map name.
3750
+ */
3751
+ getMapName() {
3752
+ return this.mapName;
3753
+ }
3754
+ /**
3755
+ * Check if this query contains FTS predicates.
3756
+ */
3757
+ hasFTSPredicate() {
3758
+ return this.filter.predicate ? this.containsFTS(this.filter.predicate) : false;
3759
+ }
3760
+ containsFTS(predicate) {
3761
+ if (predicate.op === "match" || predicate.op === "matchPhrase" || predicate.op === "matchPrefix") {
3762
+ return true;
3763
+ }
3764
+ if (predicate.children) {
3765
+ return predicate.children.some((child) => this.containsFTS(child));
3766
+ }
3767
+ return false;
3768
+ }
3769
+ };
3770
+
3098
3771
  // src/cluster/ClusterClient.ts
3099
3772
  var import_core6 = require("@topgunbuild/core");
3100
3773
 
@@ -4908,6 +5581,113 @@ var TopGunClient = class {
4908
5581
  return this.syncEngine.onBackpressure(event, listener);
4909
5582
  }
4910
5583
  // ============================================
5584
+ // Full-Text Search API (Phase 11.1a)
5585
+ // ============================================
5586
+ /**
5587
+ * Perform a one-shot BM25 search on the server.
5588
+ *
5589
+ * Searches the specified map using BM25 ranking algorithm.
5590
+ * Requires FTS to be enabled for the map on the server.
5591
+ *
5592
+ * @param mapName Name of the map to search
5593
+ * @param query Search query text
5594
+ * @param options Search options
5595
+ * @returns Promise resolving to search results sorted by relevance
5596
+ *
5597
+ * @example
5598
+ * ```typescript
5599
+ * const results = await client.search<Article>('articles', 'machine learning', {
5600
+ * limit: 20,
5601
+ * minScore: 0.5,
5602
+ * boost: { title: 2.0, body: 1.0 }
5603
+ * });
5604
+ *
5605
+ * for (const result of results) {
5606
+ * console.log(`${result.key}: ${result.value.title} (score: ${result.score})`);
5607
+ * }
5608
+ * ```
5609
+ */
5610
+ async search(mapName, query, options) {
5611
+ return this.syncEngine.search(mapName, query, options);
5612
+ }
5613
+ // ============================================
5614
+ // Live Search API (Phase 11.1b)
5615
+ // ============================================
5616
+ /**
5617
+ * Subscribe to live search results with real-time updates.
5618
+ *
5619
+ * Unlike the one-shot `search()` method, `searchSubscribe()` returns a handle
5620
+ * that receives delta updates (ENTER/UPDATE/LEAVE) when documents change.
5621
+ * This is ideal for live search UIs that need to reflect data changes.
5622
+ *
5623
+ * @param mapName Name of the map to search
5624
+ * @param query Search query text
5625
+ * @param options Search options (limit, minScore, boost)
5626
+ * @returns SearchHandle for managing the subscription
5627
+ *
5628
+ * @example
5629
+ * ```typescript
5630
+ * const handle = client.searchSubscribe<Article>('articles', 'machine learning', {
5631
+ * limit: 20,
5632
+ * minScore: 0.5
5633
+ * });
5634
+ *
5635
+ * // Subscribe to result changes
5636
+ * const unsubscribe = handle.subscribe((results) => {
5637
+ * setSearchResults(results);
5638
+ * });
5639
+ *
5640
+ * // Update query dynamically
5641
+ * handle.setQuery('deep learning');
5642
+ *
5643
+ * // Get current snapshot
5644
+ * const snapshot = handle.getResults();
5645
+ *
5646
+ * // Cleanup when done
5647
+ * handle.dispose();
5648
+ * ```
5649
+ */
5650
+ searchSubscribe(mapName, query, options) {
5651
+ return new SearchHandle(this.syncEngine, mapName, query, options);
5652
+ }
5653
+ // ============================================
5654
+ // Hybrid Query API (Phase 12)
5655
+ // ============================================
5656
+ /**
5657
+ * Create a hybrid query combining FTS with traditional filters.
5658
+ *
5659
+ * Hybrid queries allow combining full-text search predicates (match, matchPhrase, matchPrefix)
5660
+ * with traditional filter predicates (eq, gt, lt, contains, etc.) in a single query.
5661
+ * Results include relevance scores for FTS ranking.
5662
+ *
5663
+ * @param mapName Name of the map to query
5664
+ * @param filter Hybrid query filter with predicate, where, sort, limit, offset
5665
+ * @returns HybridQueryHandle for managing the subscription
5666
+ *
5667
+ * @example
5668
+ * ```typescript
5669
+ * import { Predicates } from '@topgunbuild/core';
5670
+ *
5671
+ * // Hybrid query: FTS + filter
5672
+ * const handle = client.hybridQuery<Article>('articles', {
5673
+ * predicate: Predicates.and(
5674
+ * Predicates.match('body', 'machine learning'),
5675
+ * Predicates.equal('category', 'tech')
5676
+ * ),
5677
+ * sort: { _score: 'desc' },
5678
+ * limit: 20
5679
+ * });
5680
+ *
5681
+ * // Subscribe to results
5682
+ * handle.subscribe((results) => {
5683
+ * results.forEach(r => console.log(`${r._key}: score=${r._score}`));
5684
+ * });
5685
+ * ```
5686
+ */
5687
+ hybridQuery(mapName, filter = {}) {
5688
+ return new HybridQueryHandle(this.syncEngine, mapName, filter);
5689
+ }
5690
+ // ============================================
4911
5691
  // Entry Processor API (Phase 5.03)
4912
5692
  // ============================================
4913
5693
  /**
@@ -5515,12 +6295,14 @@ var import_core9 = require("@topgunbuild/core");
5515
6295
  DEFAULT_CLUSTER_CONFIG,
5516
6296
  EncryptedStorageAdapter,
5517
6297
  EventJournalReader,
6298
+ HybridQueryHandle,
5518
6299
  IDBAdapter,
5519
6300
  LWWMap,
5520
6301
  PNCounterHandle,
5521
6302
  PartitionRouter,
5522
6303
  Predicates,
5523
6304
  QueryHandle,
6305
+ SearchHandle,
5524
6306
  SingleServerProvider,
5525
6307
  SyncEngine,
5526
6308
  SyncState,