@topgunbuild/client 0.8.0 → 0.9.0

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.mjs CHANGED
@@ -714,6 +714,11 @@ var _SyncEngine = class _SyncEngine {
714
714
  // ============================================
715
715
  /** Pending search requests by requestId */
716
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();
717
722
  if (!config.serverUrl && !config.connectionProvider) {
718
723
  throw new Error("SyncEngine requires either serverUrl or connectionProvider");
719
724
  }
@@ -2446,6 +2451,151 @@ var _SyncEngine = class _SyncEngine {
2446
2451
  getConflictResolverClient() {
2447
2452
  return this.conflictResolverClient;
2448
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
+ }
2449
2599
  };
2450
2600
  /** Default timeout for entry processor requests (ms) */
2451
2601
  _SyncEngine.PROCESSOR_TIMEOUT = 3e4;
@@ -3326,6 +3476,236 @@ var SearchHandle = class {
3326
3476
  }
3327
3477
  };
3328
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
+
3329
3709
  // src/cluster/ClusterClient.ts
3330
3710
  import {
3331
3711
  DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
@@ -5220,6 +5600,43 @@ var TopGunClient = class {
5220
5600
  return new SearchHandle(this.syncEngine, mapName, query, options);
5221
5601
  }
5222
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
+ // ============================================
5223
5640
  // Entry Processor API (Phase 5.03)
5224
5641
  // ============================================
5225
5642
  /**
@@ -5826,6 +6243,7 @@ export {
5826
6243
  DEFAULT_CLUSTER_CONFIG,
5827
6244
  EncryptedStorageAdapter,
5828
6245
  EventJournalReader,
6246
+ HybridQueryHandle,
5829
6247
  IDBAdapter,
5830
6248
  LWWMap3 as LWWMap,
5831
6249
  PNCounterHandle,