@topgunbuild/server 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.mjs CHANGED
@@ -9187,6 +9187,556 @@ var EventJournalService = class extends EventJournalImpl {
9187
9187
  }
9188
9188
  };
9189
9189
 
9190
+ // src/search/SearchCoordinator.ts
9191
+ import {
9192
+ FullTextIndex
9193
+ } from "@topgunbuild/core";
9194
+ var SearchCoordinator = class {
9195
+ constructor() {
9196
+ /** Map name → FullTextIndex */
9197
+ this.indexes = /* @__PURE__ */ new Map();
9198
+ /** Map name → FullTextIndexConfig (for reference) */
9199
+ this.configs = /* @__PURE__ */ new Map();
9200
+ // ============================================
9201
+ // Phase 11.1b: Live Search Subscription tracking
9202
+ // ============================================
9203
+ /** Subscription ID → SearchSubscription */
9204
+ this.subscriptions = /* @__PURE__ */ new Map();
9205
+ /** Map name → Set of subscription IDs */
9206
+ this.subscriptionsByMap = /* @__PURE__ */ new Map();
9207
+ /** Client ID → Set of subscription IDs */
9208
+ this.subscriptionsByClient = /* @__PURE__ */ new Map();
9209
+ // ============================================
9210
+ // Phase 11.2: Notification Batching
9211
+ // ============================================
9212
+ /** Queue of pending notifications per map */
9213
+ this.pendingNotifications = /* @__PURE__ */ new Map();
9214
+ /** Timer for batching notifications */
9215
+ this.notificationTimer = null;
9216
+ /** Batch interval in milliseconds (~1 frame at 60fps) */
9217
+ this.BATCH_INTERVAL = 16;
9218
+ logger.debug("SearchCoordinator initialized");
9219
+ }
9220
+ /**
9221
+ * Set the callback for sending updates to clients.
9222
+ * Called by ServerCoordinator during initialization.
9223
+ */
9224
+ setSendUpdateCallback(callback) {
9225
+ this.sendUpdate = callback;
9226
+ }
9227
+ /**
9228
+ * Set the callback for sending batched updates to clients.
9229
+ * When set, notifications are batched within BATCH_INTERVAL (16ms) window.
9230
+ * Called by ServerCoordinator during initialization.
9231
+ *
9232
+ * @param callback - Function to call with batched updates
9233
+ */
9234
+ setSendBatchUpdateCallback(callback) {
9235
+ this.sendBatchUpdate = callback;
9236
+ }
9237
+ /**
9238
+ * Set the callback for retrieving document values.
9239
+ * Called by ServerCoordinator during initialization.
9240
+ */
9241
+ setDocumentValueGetter(getter) {
9242
+ this.getDocumentValue = getter;
9243
+ }
9244
+ /**
9245
+ * Enable full-text search for a map.
9246
+ *
9247
+ * @param mapName - Name of the map to enable FTS for
9248
+ * @param config - FTS configuration (fields, tokenizer, bm25 options)
9249
+ */
9250
+ enableSearch(mapName, config) {
9251
+ if (this.indexes.has(mapName)) {
9252
+ logger.warn({ mapName }, "FTS already enabled for map, replacing index");
9253
+ this.indexes.delete(mapName);
9254
+ }
9255
+ const index = new FullTextIndex(config);
9256
+ this.indexes.set(mapName, index);
9257
+ this.configs.set(mapName, config);
9258
+ logger.info({ mapName, fields: config.fields }, "FTS enabled for map");
9259
+ }
9260
+ /**
9261
+ * Disable full-text search for a map.
9262
+ *
9263
+ * @param mapName - Name of the map to disable FTS for
9264
+ */
9265
+ disableSearch(mapName) {
9266
+ if (!this.indexes.has(mapName)) {
9267
+ logger.warn({ mapName }, "FTS not enabled for map, nothing to disable");
9268
+ return;
9269
+ }
9270
+ this.indexes.delete(mapName);
9271
+ this.configs.delete(mapName);
9272
+ logger.info({ mapName }, "FTS disabled for map");
9273
+ }
9274
+ /**
9275
+ * Check if FTS is enabled for a map.
9276
+ */
9277
+ isSearchEnabled(mapName) {
9278
+ return this.indexes.has(mapName);
9279
+ }
9280
+ /**
9281
+ * Get enabled map names.
9282
+ */
9283
+ getEnabledMaps() {
9284
+ return Array.from(this.indexes.keys());
9285
+ }
9286
+ /**
9287
+ * Execute a one-shot search query.
9288
+ *
9289
+ * @param mapName - Name of the map to search
9290
+ * @param query - Search query text
9291
+ * @param options - Search options (limit, minScore, boost)
9292
+ * @returns Search response payload
9293
+ */
9294
+ search(mapName, query, options) {
9295
+ const index = this.indexes.get(mapName);
9296
+ if (!index) {
9297
+ logger.warn({ mapName }, "Search requested for map without FTS enabled");
9298
+ return {
9299
+ requestId: "",
9300
+ results: [],
9301
+ totalCount: 0,
9302
+ error: `Full-text search not enabled for map: ${mapName}`
9303
+ };
9304
+ }
9305
+ try {
9306
+ const searchResults = index.search(query, options);
9307
+ const results = searchResults.map((result) => {
9308
+ const value = this.getDocumentValue ? this.getDocumentValue(mapName, result.docId) : void 0;
9309
+ return {
9310
+ key: result.docId,
9311
+ value,
9312
+ score: result.score,
9313
+ matchedTerms: result.matchedTerms || []
9314
+ };
9315
+ });
9316
+ logger.debug(
9317
+ { mapName, query, resultCount: results.length },
9318
+ "Search executed"
9319
+ );
9320
+ return {
9321
+ requestId: "",
9322
+ results,
9323
+ totalCount: searchResults.length
9324
+ };
9325
+ } catch (err) {
9326
+ logger.error({ mapName, query, err }, "Search failed");
9327
+ return {
9328
+ requestId: "",
9329
+ results: [],
9330
+ totalCount: 0,
9331
+ error: `Search failed: ${err.message}`
9332
+ };
9333
+ }
9334
+ }
9335
+ /**
9336
+ * Handle document set/update.
9337
+ * Called by ServerCoordinator when data changes.
9338
+ *
9339
+ * @param mapName - Name of the map
9340
+ * @param key - Document key
9341
+ * @param value - Document value
9342
+ */
9343
+ onDataChange(mapName, key, value, changeType) {
9344
+ const index = this.indexes.get(mapName);
9345
+ if (!index) {
9346
+ return;
9347
+ }
9348
+ if (changeType === "remove" || value === null || value === void 0) {
9349
+ index.onRemove(key);
9350
+ } else {
9351
+ index.onSet(key, value);
9352
+ }
9353
+ this.notifySubscribers(mapName, key, value ?? null, changeType);
9354
+ }
9355
+ /**
9356
+ * Build index from existing map entries.
9357
+ * Called when FTS is enabled for a map that already has data.
9358
+ *
9359
+ * @param mapName - Name of the map
9360
+ * @param entries - Iterator of [key, value] tuples
9361
+ */
9362
+ buildIndexFromEntries(mapName, entries) {
9363
+ const index = this.indexes.get(mapName);
9364
+ if (!index) {
9365
+ logger.warn({ mapName }, "Cannot build index: FTS not enabled for map");
9366
+ return;
9367
+ }
9368
+ let count = 0;
9369
+ for (const [key, value] of entries) {
9370
+ if (value !== null) {
9371
+ index.onSet(key, value);
9372
+ count++;
9373
+ }
9374
+ }
9375
+ logger.info({ mapName, documentCount: count }, "Index built from entries");
9376
+ }
9377
+ /**
9378
+ * Get index statistics for monitoring.
9379
+ */
9380
+ getIndexStats(mapName) {
9381
+ const index = this.indexes.get(mapName);
9382
+ const config = this.configs.get(mapName);
9383
+ if (!index || !config) {
9384
+ return null;
9385
+ }
9386
+ return {
9387
+ documentCount: index.getSize(),
9388
+ fields: config.fields
9389
+ };
9390
+ }
9391
+ /**
9392
+ * Clear all indexes (for testing or shutdown).
9393
+ */
9394
+ clear() {
9395
+ for (const index of this.indexes.values()) {
9396
+ index.clear();
9397
+ }
9398
+ this.indexes.clear();
9399
+ this.configs.clear();
9400
+ this.subscriptions.clear();
9401
+ this.subscriptionsByMap.clear();
9402
+ this.subscriptionsByClient.clear();
9403
+ this.pendingNotifications.clear();
9404
+ if (this.notificationTimer) {
9405
+ clearTimeout(this.notificationTimer);
9406
+ this.notificationTimer = null;
9407
+ }
9408
+ logger.debug("SearchCoordinator cleared");
9409
+ }
9410
+ // ============================================
9411
+ // Phase 11.1b: Live Search Subscription Methods
9412
+ // ============================================
9413
+ /**
9414
+ * Subscribe to live search results.
9415
+ * Returns initial results and tracks the subscription for delta updates.
9416
+ *
9417
+ * @param clientId - ID of the subscribing client
9418
+ * @param subscriptionId - Unique subscription identifier
9419
+ * @param mapName - Name of the map to search
9420
+ * @param query - Search query text
9421
+ * @param options - Search options (limit, minScore, boost)
9422
+ * @returns Initial search results
9423
+ */
9424
+ subscribe(clientId, subscriptionId, mapName, query, options) {
9425
+ const index = this.indexes.get(mapName);
9426
+ if (!index) {
9427
+ logger.warn({ mapName }, "Subscribe requested for map without FTS enabled");
9428
+ return [];
9429
+ }
9430
+ const queryTerms = index.tokenizeQuery(query);
9431
+ const searchResults = index.search(query, options);
9432
+ const currentResults = /* @__PURE__ */ new Map();
9433
+ const results = [];
9434
+ for (const result of searchResults) {
9435
+ const value = this.getDocumentValue ? this.getDocumentValue(mapName, result.docId) : void 0;
9436
+ currentResults.set(result.docId, {
9437
+ score: result.score,
9438
+ matchedTerms: result.matchedTerms || []
9439
+ });
9440
+ results.push({
9441
+ key: result.docId,
9442
+ value,
9443
+ score: result.score,
9444
+ matchedTerms: result.matchedTerms || []
9445
+ });
9446
+ }
9447
+ const subscription = {
9448
+ id: subscriptionId,
9449
+ clientId,
9450
+ mapName,
9451
+ query,
9452
+ queryTerms,
9453
+ options: options || {},
9454
+ currentResults
9455
+ };
9456
+ this.subscriptions.set(subscriptionId, subscription);
9457
+ if (!this.subscriptionsByMap.has(mapName)) {
9458
+ this.subscriptionsByMap.set(mapName, /* @__PURE__ */ new Set());
9459
+ }
9460
+ this.subscriptionsByMap.get(mapName).add(subscriptionId);
9461
+ if (!this.subscriptionsByClient.has(clientId)) {
9462
+ this.subscriptionsByClient.set(clientId, /* @__PURE__ */ new Set());
9463
+ }
9464
+ this.subscriptionsByClient.get(clientId).add(subscriptionId);
9465
+ logger.debug(
9466
+ { subscriptionId, clientId, mapName, query, resultCount: results.length },
9467
+ "Search subscription created"
9468
+ );
9469
+ return results;
9470
+ }
9471
+ /**
9472
+ * Unsubscribe from a live search.
9473
+ *
9474
+ * @param subscriptionId - Subscription to remove
9475
+ */
9476
+ unsubscribe(subscriptionId) {
9477
+ const subscription = this.subscriptions.get(subscriptionId);
9478
+ if (!subscription) {
9479
+ return;
9480
+ }
9481
+ this.subscriptions.delete(subscriptionId);
9482
+ const mapSubs = this.subscriptionsByMap.get(subscription.mapName);
9483
+ if (mapSubs) {
9484
+ mapSubs.delete(subscriptionId);
9485
+ if (mapSubs.size === 0) {
9486
+ this.subscriptionsByMap.delete(subscription.mapName);
9487
+ }
9488
+ }
9489
+ const clientSubs = this.subscriptionsByClient.get(subscription.clientId);
9490
+ if (clientSubs) {
9491
+ clientSubs.delete(subscriptionId);
9492
+ if (clientSubs.size === 0) {
9493
+ this.subscriptionsByClient.delete(subscription.clientId);
9494
+ }
9495
+ }
9496
+ logger.debug({ subscriptionId }, "Search subscription removed");
9497
+ }
9498
+ /**
9499
+ * Unsubscribe all subscriptions for a client.
9500
+ * Called when a client disconnects.
9501
+ *
9502
+ * @param clientId - ID of the disconnected client
9503
+ */
9504
+ unsubscribeClient(clientId) {
9505
+ const clientSubs = this.subscriptionsByClient.get(clientId);
9506
+ if (!clientSubs) {
9507
+ return;
9508
+ }
9509
+ const subscriptionIds = Array.from(clientSubs);
9510
+ for (const subscriptionId of subscriptionIds) {
9511
+ this.unsubscribe(subscriptionId);
9512
+ }
9513
+ logger.debug({ clientId, count: subscriptionIds.length }, "Client subscriptions cleared");
9514
+ }
9515
+ /**
9516
+ * Get the number of active subscriptions.
9517
+ */
9518
+ getSubscriptionCount() {
9519
+ return this.subscriptions.size;
9520
+ }
9521
+ /**
9522
+ * Notify subscribers about a document change.
9523
+ * Computes delta (ENTER/UPDATE/LEAVE) for each affected subscription.
9524
+ *
9525
+ * @param mapName - Name of the map that changed
9526
+ * @param key - Document key that changed
9527
+ * @param value - New document value (null if removed)
9528
+ * @param changeType - Type of change
9529
+ */
9530
+ notifySubscribers(mapName, key, value, changeType) {
9531
+ if (!this.sendUpdate) {
9532
+ return;
9533
+ }
9534
+ const subscriptionIds = this.subscriptionsByMap.get(mapName);
9535
+ if (!subscriptionIds || subscriptionIds.size === 0) {
9536
+ return;
9537
+ }
9538
+ const index = this.indexes.get(mapName);
9539
+ if (!index) {
9540
+ return;
9541
+ }
9542
+ for (const subId of subscriptionIds) {
9543
+ const sub = this.subscriptions.get(subId);
9544
+ if (!sub) continue;
9545
+ const wasInResults = sub.currentResults.has(key);
9546
+ let isInResults = false;
9547
+ let newScore = 0;
9548
+ let matchedTerms = [];
9549
+ logger.debug({ subId, key, wasInResults, changeType }, "Processing subscription update");
9550
+ if (changeType !== "remove" && value !== null) {
9551
+ const result = this.scoreDocument(sub, key, value, index);
9552
+ if (result && result.score >= (sub.options.minScore ?? 0)) {
9553
+ isInResults = true;
9554
+ newScore = result.score;
9555
+ matchedTerms = result.matchedTerms;
9556
+ }
9557
+ }
9558
+ let updateType = null;
9559
+ if (!wasInResults && isInResults) {
9560
+ updateType = "ENTER";
9561
+ sub.currentResults.set(key, { score: newScore, matchedTerms });
9562
+ } else if (wasInResults && !isInResults) {
9563
+ updateType = "LEAVE";
9564
+ sub.currentResults.delete(key);
9565
+ } else if (wasInResults && isInResults) {
9566
+ const old = sub.currentResults.get(key);
9567
+ if (Math.abs(old.score - newScore) > 1e-4 || changeType === "update") {
9568
+ updateType = "UPDATE";
9569
+ sub.currentResults.set(key, { score: newScore, matchedTerms });
9570
+ }
9571
+ }
9572
+ logger.debug({ subId, key, wasInResults, isInResults, updateType, newScore }, "Update decision");
9573
+ if (updateType) {
9574
+ this.sendUpdate(
9575
+ sub.clientId,
9576
+ subId,
9577
+ key,
9578
+ value,
9579
+ newScore,
9580
+ matchedTerms,
9581
+ updateType
9582
+ );
9583
+ }
9584
+ }
9585
+ }
9586
+ /**
9587
+ * Score a single document against a subscription's query.
9588
+ *
9589
+ * OPTIMIZED: O(Q × D) complexity instead of O(N) full index scan.
9590
+ * Uses pre-tokenized queryTerms and FullTextIndex.scoreSingleDocument().
9591
+ *
9592
+ * @param subscription - The subscription containing query and cached queryTerms
9593
+ * @param key - Document key
9594
+ * @param value - Document value
9595
+ * @param index - The FullTextIndex for this map
9596
+ * @returns Scored result or null if document doesn't match
9597
+ */
9598
+ scoreDocument(subscription, key, value, index) {
9599
+ const result = index.scoreSingleDocument(key, subscription.queryTerms, value);
9600
+ if (!result) {
9601
+ return null;
9602
+ }
9603
+ return {
9604
+ score: result.score,
9605
+ matchedTerms: result.matchedTerms || []
9606
+ };
9607
+ }
9608
+ // ============================================
9609
+ // Phase 11.2: Notification Batching Methods
9610
+ // ============================================
9611
+ /**
9612
+ * Queue a notification for batched processing.
9613
+ * Notifications are collected and processed together after BATCH_INTERVAL.
9614
+ *
9615
+ * @param mapName - Name of the map that changed
9616
+ * @param key - Document key that changed
9617
+ * @param value - New document value (null if removed)
9618
+ * @param changeType - Type of change
9619
+ */
9620
+ queueNotification(mapName, key, value, changeType) {
9621
+ if (!this.sendBatchUpdate) {
9622
+ this.notifySubscribers(mapName, key, value, changeType);
9623
+ return;
9624
+ }
9625
+ const notification = { key, value, changeType };
9626
+ if (!this.pendingNotifications.has(mapName)) {
9627
+ this.pendingNotifications.set(mapName, []);
9628
+ }
9629
+ this.pendingNotifications.get(mapName).push(notification);
9630
+ this.scheduleNotificationFlush();
9631
+ }
9632
+ /**
9633
+ * Schedule a flush of pending notifications.
9634
+ * Uses setTimeout to batch notifications within BATCH_INTERVAL window.
9635
+ */
9636
+ scheduleNotificationFlush() {
9637
+ if (this.notificationTimer) {
9638
+ return;
9639
+ }
9640
+ this.notificationTimer = setTimeout(() => {
9641
+ this.flushNotifications();
9642
+ this.notificationTimer = null;
9643
+ }, this.BATCH_INTERVAL);
9644
+ }
9645
+ /**
9646
+ * Flush all pending notifications.
9647
+ * Processes each map's notifications and sends batched updates.
9648
+ */
9649
+ flushNotifications() {
9650
+ if (this.pendingNotifications.size === 0) {
9651
+ return;
9652
+ }
9653
+ for (const [mapName, notifications] of this.pendingNotifications) {
9654
+ this.processBatchedNotifications(mapName, notifications);
9655
+ }
9656
+ this.pendingNotifications.clear();
9657
+ }
9658
+ /**
9659
+ * Process batched notifications for a single map.
9660
+ * Computes updates for each subscription and sends as a batch.
9661
+ *
9662
+ * @param mapName - Name of the map
9663
+ * @param notifications - Array of pending notifications
9664
+ */
9665
+ processBatchedNotifications(mapName, notifications) {
9666
+ const subscriptionIds = this.subscriptionsByMap.get(mapName);
9667
+ if (!subscriptionIds || subscriptionIds.size === 0) {
9668
+ return;
9669
+ }
9670
+ const index = this.indexes.get(mapName);
9671
+ if (!index) {
9672
+ return;
9673
+ }
9674
+ for (const subId of subscriptionIds) {
9675
+ const sub = this.subscriptions.get(subId);
9676
+ if (!sub) continue;
9677
+ const updates = [];
9678
+ for (const { key, value, changeType } of notifications) {
9679
+ const update = this.computeSubscriptionUpdate(sub, key, value, changeType, index);
9680
+ if (update) {
9681
+ updates.push(update);
9682
+ }
9683
+ }
9684
+ if (updates.length > 0 && this.sendBatchUpdate) {
9685
+ this.sendBatchUpdate(sub.clientId, subId, updates);
9686
+ }
9687
+ }
9688
+ }
9689
+ /**
9690
+ * Compute the update for a single document change against a subscription.
9691
+ * Returns null if no update is needed.
9692
+ *
9693
+ * @param subscription - The subscription to check
9694
+ * @param key - Document key
9695
+ * @param value - Document value (null if removed)
9696
+ * @param changeType - Type of change
9697
+ * @param index - The FullTextIndex for this map
9698
+ * @returns BatchedUpdate or null
9699
+ */
9700
+ computeSubscriptionUpdate(subscription, key, value, changeType, index) {
9701
+ const wasInResults = subscription.currentResults.has(key);
9702
+ let isInResults = false;
9703
+ let newScore = 0;
9704
+ let matchedTerms = [];
9705
+ if (changeType !== "remove" && value !== null) {
9706
+ const result = this.scoreDocument(subscription, key, value, index);
9707
+ if (result && result.score >= (subscription.options.minScore ?? 0)) {
9708
+ isInResults = true;
9709
+ newScore = result.score;
9710
+ matchedTerms = result.matchedTerms;
9711
+ }
9712
+ }
9713
+ let updateType = null;
9714
+ if (!wasInResults && isInResults) {
9715
+ updateType = "ENTER";
9716
+ subscription.currentResults.set(key, { score: newScore, matchedTerms });
9717
+ } else if (wasInResults && !isInResults) {
9718
+ updateType = "LEAVE";
9719
+ subscription.currentResults.delete(key);
9720
+ } else if (wasInResults && isInResults) {
9721
+ const old = subscription.currentResults.get(key);
9722
+ if (Math.abs(old.score - newScore) > 1e-4 || changeType === "update") {
9723
+ updateType = "UPDATE";
9724
+ subscription.currentResults.set(key, { score: newScore, matchedTerms });
9725
+ }
9726
+ }
9727
+ if (!updateType) {
9728
+ return null;
9729
+ }
9730
+ return {
9731
+ key,
9732
+ value,
9733
+ score: newScore,
9734
+ matchedTerms,
9735
+ type: updateType
9736
+ };
9737
+ }
9738
+ };
9739
+
9190
9740
  // src/ServerCoordinator.ts
9191
9741
  var GC_INTERVAL_MS = 60 * 60 * 1e3;
9192
9742
  var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -9444,6 +9994,34 @@ var ServerCoordinator = class {
9444
9994
  );
9445
9995
  this.repairScheduler.start();
9446
9996
  logger.info("MerkleTreeManager and RepairScheduler initialized");
9997
+ this.searchCoordinator = new SearchCoordinator();
9998
+ this.searchCoordinator.setDocumentValueGetter((mapName, key) => {
9999
+ const map = this.maps.get(mapName);
10000
+ if (!map) return void 0;
10001
+ return map.get(key);
10002
+ });
10003
+ this.searchCoordinator.setSendUpdateCallback((clientId, subscriptionId, key, value, score, matchedTerms, type) => {
10004
+ const client = this.clients.get(clientId);
10005
+ if (client) {
10006
+ client.writer.write({
10007
+ type: "SEARCH_UPDATE",
10008
+ payload: {
10009
+ subscriptionId,
10010
+ key,
10011
+ value,
10012
+ score,
10013
+ matchedTerms,
10014
+ type
10015
+ }
10016
+ });
10017
+ }
10018
+ });
10019
+ if (config.fullTextSearch) {
10020
+ for (const [mapName, ftsConfig] of Object.entries(config.fullTextSearch)) {
10021
+ this.searchCoordinator.enableSearch(mapName, ftsConfig);
10022
+ logger.info({ mapName, fields: ftsConfig.fields }, "FTS enabled for map");
10023
+ }
10024
+ }
9447
10025
  this.systemManager = new SystemManager(
9448
10026
  this.cluster,
9449
10027
  this.metricsService,
@@ -9467,6 +10045,7 @@ var ServerCoordinator = class {
9467
10045
  if (this.storage) {
9468
10046
  this.storage.initialize().then(() => {
9469
10047
  logger.info("Storage adapter initialized");
10048
+ this.backfillSearchIndexes();
9470
10049
  }).catch((err) => {
9471
10050
  logger.error({ err }, "Failed to initialize storage");
9472
10051
  });
@@ -9474,6 +10053,36 @@ var ServerCoordinator = class {
9474
10053
  this.startGarbageCollection();
9475
10054
  this.startHeartbeatCheck();
9476
10055
  }
10056
+ /**
10057
+ * Populate FTS indexes from existing map data.
10058
+ * Called after storage initialization.
10059
+ */
10060
+ async backfillSearchIndexes() {
10061
+ const enabledMaps = this.searchCoordinator.getEnabledMaps();
10062
+ const promises2 = enabledMaps.map(async (mapName) => {
10063
+ try {
10064
+ await this.getMapAsync(mapName);
10065
+ const map = this.maps.get(mapName);
10066
+ if (!map) return;
10067
+ if (map instanceof LWWMap3) {
10068
+ const entries = Array.from(map.entries());
10069
+ if (entries.length > 0) {
10070
+ logger.info({ mapName, count: entries.length }, "Backfilling FTS index");
10071
+ this.searchCoordinator.buildIndexFromEntries(
10072
+ mapName,
10073
+ map.entries()
10074
+ );
10075
+ }
10076
+ } else {
10077
+ logger.warn({ mapName }, "FTS backfill skipped: Map type not supported (only LWWMap)");
10078
+ }
10079
+ } catch (err) {
10080
+ logger.error({ mapName, err }, "Failed to backfill FTS index");
10081
+ }
10082
+ });
10083
+ await Promise.all(promises2);
10084
+ logger.info("FTS backfill completed");
10085
+ }
9477
10086
  /** Wait for server to be fully ready (ports assigned) */
9478
10087
  ready() {
9479
10088
  return this._readyPromise;
@@ -9540,6 +10149,59 @@ var ServerCoordinator = class {
9540
10149
  getTaskletScheduler() {
9541
10150
  return this.taskletScheduler;
9542
10151
  }
10152
+ // === Phase 11.1: Full-Text Search Public API ===
10153
+ /**
10154
+ * Enable full-text search for a map.
10155
+ * Can be called at runtime to enable FTS dynamically.
10156
+ *
10157
+ * @param mapName - Name of the map to enable FTS for
10158
+ * @param config - FTS configuration (fields, tokenizer, bm25 options)
10159
+ */
10160
+ enableFullTextSearch(mapName, config) {
10161
+ this.searchCoordinator.enableSearch(mapName, config);
10162
+ const map = this.maps.get(mapName);
10163
+ if (map) {
10164
+ const entries = [];
10165
+ if (map instanceof LWWMap3) {
10166
+ for (const [key, value] of map.entries()) {
10167
+ entries.push([key, value]);
10168
+ }
10169
+ } else if (map instanceof ORMap2) {
10170
+ for (const key of map.allKeys()) {
10171
+ const values = map.get(key);
10172
+ const value = values.length > 0 ? values[0] : null;
10173
+ entries.push([key, value]);
10174
+ }
10175
+ }
10176
+ this.searchCoordinator.buildIndexFromEntries(mapName, entries);
10177
+ }
10178
+ }
10179
+ /**
10180
+ * Disable full-text search for a map.
10181
+ *
10182
+ * @param mapName - Name of the map to disable FTS for
10183
+ */
10184
+ disableFullTextSearch(mapName) {
10185
+ this.searchCoordinator.disableSearch(mapName);
10186
+ }
10187
+ /**
10188
+ * Check if full-text search is enabled for a map.
10189
+ *
10190
+ * @param mapName - Name of the map to check
10191
+ * @returns True if FTS is enabled
10192
+ */
10193
+ isFullTextSearchEnabled(mapName) {
10194
+ return this.searchCoordinator.isSearchEnabled(mapName);
10195
+ }
10196
+ /**
10197
+ * Get FTS index statistics for a map.
10198
+ *
10199
+ * @param mapName - Name of the map
10200
+ * @returns Index stats or null if FTS not enabled
10201
+ */
10202
+ getFullTextSearchStats(mapName) {
10203
+ return this.searchCoordinator.getIndexStats(mapName);
10204
+ }
9543
10205
  /**
9544
10206
  * Phase 10.02: Graceful cluster departure
9545
10207
  *
@@ -9798,6 +10460,7 @@ var ServerCoordinator = class {
9798
10460
  this.lockManager.handleClientDisconnect(clientId);
9799
10461
  this.topicManager.unsubscribeAll(clientId);
9800
10462
  this.counterHandler.unsubscribeAll(clientId);
10463
+ this.searchCoordinator.unsubscribeClient(clientId);
9801
10464
  const members = this.cluster.getMembers();
9802
10465
  for (const memberId of members) {
9803
10466
  if (!this.cluster.isLocal(memberId)) {
@@ -10739,6 +11402,106 @@ var ServerCoordinator = class {
10739
11402
  });
10740
11403
  break;
10741
11404
  }
11405
+ // Phase 11.1: Full-Text Search
11406
+ case "SEARCH": {
11407
+ const { requestId: searchReqId, mapName: searchMapName, query: searchQuery, options: searchOptions } = message.payload;
11408
+ if (!this.securityManager.checkPermission(client.principal, searchMapName, "READ")) {
11409
+ logger.warn({ clientId: client.id, mapName: searchMapName }, "Access Denied: SEARCH");
11410
+ client.writer.write({
11411
+ type: "SEARCH_RESP",
11412
+ payload: {
11413
+ requestId: searchReqId,
11414
+ results: [],
11415
+ totalCount: 0,
11416
+ error: `Access denied for map: ${searchMapName}`
11417
+ }
11418
+ });
11419
+ break;
11420
+ }
11421
+ if (!this.searchCoordinator.isSearchEnabled(searchMapName)) {
11422
+ client.writer.write({
11423
+ type: "SEARCH_RESP",
11424
+ payload: {
11425
+ requestId: searchReqId,
11426
+ results: [],
11427
+ totalCount: 0,
11428
+ error: `Full-text search not enabled for map: ${searchMapName}`
11429
+ }
11430
+ });
11431
+ break;
11432
+ }
11433
+ const searchResult = this.searchCoordinator.search(searchMapName, searchQuery, searchOptions);
11434
+ searchResult.requestId = searchReqId;
11435
+ logger.debug({
11436
+ clientId: client.id,
11437
+ mapName: searchMapName,
11438
+ query: searchQuery,
11439
+ resultCount: searchResult.results.length
11440
+ }, "Search executed");
11441
+ client.writer.write({
11442
+ type: "SEARCH_RESP",
11443
+ payload: searchResult
11444
+ });
11445
+ break;
11446
+ }
11447
+ // Phase 11.1b: Live Search Subscriptions
11448
+ case "SEARCH_SUB": {
11449
+ const { subscriptionId, mapName: subMapName, query: subQuery, options: subOptions } = message.payload;
11450
+ if (!this.securityManager.checkPermission(client.principal, subMapName, "READ")) {
11451
+ logger.warn({ clientId: client.id, mapName: subMapName }, "Access Denied: SEARCH_SUB");
11452
+ client.writer.write({
11453
+ type: "SEARCH_RESP",
11454
+ payload: {
11455
+ requestId: subscriptionId,
11456
+ results: [],
11457
+ totalCount: 0,
11458
+ error: `Access denied for map: ${subMapName}`
11459
+ }
11460
+ });
11461
+ break;
11462
+ }
11463
+ if (!this.searchCoordinator.isSearchEnabled(subMapName)) {
11464
+ client.writer.write({
11465
+ type: "SEARCH_RESP",
11466
+ payload: {
11467
+ requestId: subscriptionId,
11468
+ results: [],
11469
+ totalCount: 0,
11470
+ error: `Full-text search not enabled for map: ${subMapName}`
11471
+ }
11472
+ });
11473
+ break;
11474
+ }
11475
+ const initialResults = this.searchCoordinator.subscribe(
11476
+ client.id,
11477
+ subscriptionId,
11478
+ subMapName,
11479
+ subQuery,
11480
+ subOptions
11481
+ );
11482
+ logger.debug({
11483
+ clientId: client.id,
11484
+ subscriptionId,
11485
+ mapName: subMapName,
11486
+ query: subQuery,
11487
+ resultCount: initialResults.length
11488
+ }, "Search subscription created");
11489
+ client.writer.write({
11490
+ type: "SEARCH_RESP",
11491
+ payload: {
11492
+ requestId: subscriptionId,
11493
+ results: initialResults,
11494
+ totalCount: initialResults.length
11495
+ }
11496
+ });
11497
+ break;
11498
+ }
11499
+ case "SEARCH_UNSUB": {
11500
+ const { subscriptionId: unsubId } = message.payload;
11501
+ this.searchCoordinator.unsubscribe(unsubId);
11502
+ logger.debug({ clientId: client.id, subscriptionId: unsubId }, "Search unsubscription");
11503
+ break;
11504
+ }
10742
11505
  default:
10743
11506
  logger.warn({ type: message.type }, "Unknown message type");
10744
11507
  }
@@ -11475,6 +12238,12 @@ var ServerCoordinator = class {
11475
12238
  const partitionId = this.partitionService.getPartitionId(op.key);
11476
12239
  this.merkleTreeManager.updateRecord(partitionId, op.key, recordToStore);
11477
12240
  }
12241
+ if (this.searchCoordinator.isSearchEnabled(op.mapName)) {
12242
+ const isRemove = op.opType === "REMOVE" || op.record && op.record.value === null;
12243
+ const value = isRemove ? null : op.record?.value ?? op.orRecord?.value;
12244
+ const changeType = isRemove ? "remove" : oldRecord ? "update" : "add";
12245
+ this.searchCoordinator.onDataChange(op.mapName, op.key, value, changeType);
12246
+ }
11478
12247
  return { eventPayload, oldRecord };
11479
12248
  }
11480
12249
  /**
@@ -13601,6 +14370,7 @@ export {
13601
14370
  ReduceTasklet,
13602
14371
  RepairScheduler,
13603
14372
  ReplicationPipeline,
14373
+ SearchCoordinator,
13604
14374
  SecurityManager,
13605
14375
  ServerCoordinator,
13606
14376
  TaskletScheduler,