cry-search 1.0.5 → 1.0.7

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/CLAUDE.md CHANGED
@@ -315,6 +315,31 @@ Override match behavior per token at search time. Priority: query prefix > field
315
315
  5. only processes items whose foreign key matches primaryId
316
316
  6. returns { added: number, updated: number, removed: number }
317
317
 
318
+ 13. searchAndRank(query, data, metadata, options): ScoredResult<T>[]
319
+ 1. searchAndRank = searchAndRankNumeric (alias for the numeric implementation)
320
+ 2. wraps searchInDataReturnIds + positional scoring over the candidate set
321
+ 3. options: { primaryTextFn, exactKeysFn?, limit?, opts? }
322
+ - primaryTextFn: (item) => string (e.g. item.naziv) — used for positional scoring
323
+ - exactKeysFn: (item) => readonly string[] | undefined (e.g. crtnaKoda)
324
+ exact equality with raw query returns score 100_000
325
+ 4. results sorted by score desc, locale-aware primary-text asc as tiebreaker
326
+ 5. scoring components (additive):
327
+ - +100_000 exact key match
328
+ - +50 base for any subsequence-prefix match in primary text
329
+ - +100 all query tokens matched (in order)
330
+ - +50 first matched position is 0
331
+ - +80 perfect prefix (positions === [0..k-1])
332
+ - +30 * (matchedCount / span) density
333
+ - -0.1 * primaryText.length length penalty
334
+ 6. returns 1 if candidate matched only via secondary field (no primary token match)
335
+ 14. orderedTokenize(input: string): string[]
336
+ 1. prepareStringForSearch pipeline minus the trailing .sort()
337
+ 2. required for any positional / order-aware scoring
338
+ 3. do NOT use these tokens for candidate retrieval (sorted lists required)
339
+ 15. scoreOne(item, query, qTokens, primaryTextFn, exactKeys?): number
340
+ 1. single-item scoring primitive — use to compose custom rankers
341
+ 2. callers must pre-tokenize the query with orderedTokenize for correct positional semantics
342
+
318
343
  ## Token Registry Functions
319
344
 
320
345
  1. tokens: string[] - global array (read-only)
@@ -373,6 +398,7 @@ src/
373
398
  │ │ ├── updateSearchMetadata.ts
374
399
  │ │ ├── updateSearchLinkedMetadata.ts
375
400
  │ │ ├── searchInData.ts
401
+ │ │ ├── searchAndRank.ts # Ranked search with positional scoring
376
402
  │ │ ├── syncSearchArrayMetadata.ts
377
403
  │ │ └── AugmentedMetadata.ts
378
404
  │ ├── string/ # Legacy string implementation
package/README.md CHANGED
@@ -11,6 +11,7 @@ A fast, memory-efficient search library for large datasets with support for toke
11
11
  - **Flexible matching** - Per-field match modes and query prefixes for prefix, suffix, anywhere, exact, and negation
12
12
  - **Search prefixes** - use `-word`, `--word`, or `~word` to exclude, `..word` for suffix, `=word` for exact match
13
13
  - **Automatic normalization** - Handles diacritics, case, dates, and number formatting
14
+ - **Ranked search** - `searchAndRank` returns results scored by positional relevance (token order, contiguity, perfect-prefix, length)
14
15
  - **[Universal search](./UNIVERSE.md)** - Search and update across linked collections (e.g., customers with their pets)
15
16
 
16
17
  ## Table of Contents
@@ -24,6 +25,7 @@ A fast, memory-efficient search library for large datasets with support for toke
24
25
  - [SearchUniverse with Linked Collections](#searchuniverse-with-linked-collections)
25
26
  - [Updating Existing Data](#updating-existing-data)
26
27
  - [Augmented Metadata - Local Search Context](#augmented-metadata---local-search-context)
28
+ - [Ranked Search with Positional Scoring](#ranked-search-with-positional-scoring)
27
29
  - [Architecture](#architecture)
28
30
  - [Text Processing Pipeline](#text-processing-pipeline)
29
31
  - [Metadata Building](#metadata-building)
@@ -278,6 +280,69 @@ augmented.clearAllAugmented(); // Reset to global state
278
280
  - Documents with review status or approval state
279
281
  - Items with computed scores or temporary categories
280
282
 
283
+ ### Ranked Search with Positional Scoring
284
+
285
+ `searchInDataReturnIds` returns matching candidates but no ordering signal — useful when the caller needs raw IDs (e.g., for further filtering) but not when the UI needs the *most relevant* match first.
286
+
287
+ `searchAndRank` wraps candidate retrieval with positional relevance scoring. Each item gets a score that rewards in-order token matches, first-word matches, perfect-prefix matches, contiguous matches, and shorter primary text.
288
+
289
+ ```typescript
290
+ import {
291
+ arrayToSearchableData,
292
+ createSearchArrayMetadata,
293
+ searchAndRank,
294
+ type SearchableObject,
295
+ } from "cry-search";
296
+
297
+ interface Product extends SearchableObject {
298
+ _id: string;
299
+ name: string;
300
+ barcodes?: string[];
301
+ }
302
+
303
+ const products: Product[] = [
304
+ { _id: "1", name: "POTROŠNI MATERIAL" },
305
+ { _id: "2", name: "POTROŠNI MATERIAL-SUCRALAN 1ML" },
306
+ { _id: "3", name: "KONTROLNI PREGLED" },
307
+ { _id: "4", name: "DERMATOLOŠKI KONTROLNI PREGLED" },
308
+ ];
309
+
310
+ const data = arrayToSearchableData(products);
311
+ const meta = createSearchArrayMetadata(data);
312
+
313
+ // Returns items sorted by relevance, NOT discovery order:
314
+ const results = searchAndRank("pot mat", data, meta, {
315
+ primaryTextFn: (p) => p.name,
316
+ exactKeysFn: (p) => p.barcodes, // optional: exact-key boost (e.g. barcode)
317
+ limit: 10,
318
+ });
319
+
320
+ // results[0].item.name === "POTROŠNI MATERIAL" (perfect prefix → +80)
321
+ // results[1].item.name === "POTROŠNI MATERIAL-SUCRALAN 1ML" (longer naziv)
322
+
323
+ // Each result: { id: Id, item: T, score: number }
324
+ ```
325
+
326
+ **Scoring formula** (`scoreOne` is exported as a primitive — useful if you want to compose your own scoring):
327
+
328
+ | Component | Value | When |
329
+ |---|---|---|
330
+ | Exact-key match | **+100 000** | `query === any(exactKeys)` (e.g. barcode equality) |
331
+ | Base | +50 | Any in-order subsequence-prefix match of query tokens in `primaryText` |
332
+ | All tokens matched | +100 | Every query token had a target |
333
+ | First-word match | +50 | First match at position 0 |
334
+ | Perfect prefix | +80 | Matches at positions `[0,1,…,k-1]` (contiguous from start) |
335
+ | Density | +30 × (matched / span) | Rewards contiguous matches over gapped |
336
+ | Length penalty | -0.1 × `primaryText.length` | Weak tiebreaker — shorter wins |
337
+ | Secondary-field-only | 1 | Item matched (via search) but `primaryText` contains no query token |
338
+
339
+ **Lower-level primitives** are also exported, for building custom rankers:
340
+
341
+ - `orderedTokenize(input: string): string[]` — tokenize *without* alphabetic sort. `prepareStringForSearch` sorts tokens alphabetically (required for binary-search retrieval), which loses positional info. Use this for any order-aware scoring.
342
+ - `scoreOne(item, query, qTokens, primaryTextFn, exactKeys?): number` — score one item against a pre-tokenized query. Compose with your own retrieval or filtering.
343
+
344
+ **Important**: `orderedTokenize` output is NOT compatible with `searchInData...` retrieval functions — those require the sorted lists from `prepareStringForSearch`.
345
+
281
346
  ## Architecture
282
347
 
283
348
  cry-search uses a two-phase approach: **build** metadata once, then **search** instantly.
package/UNIVERSE.md CHANGED
@@ -620,6 +620,59 @@ SearchUniverse provides methods to update data and metadata after initial load.
620
620
  └─────────────────────────────────────────────────────────────────────────────┘
621
621
  ```
622
622
 
623
+ ### Sync Collection (Snapshot Diff)
624
+
625
+ ```
626
+ ┌─────────────────────────────────────────────────────────────────────────────┐
627
+ │ universe.syncCollection(collectionName, newItems, options?) │
628
+ ├─────────────────────────────────────────────────────────────────────────────┤
629
+ │ │
630
+ │ ⚠️ newItems MUST be the FULL snapshot, not a partial delta. │
631
+ │ Anything currently in the collection whose _id is missing from │
632
+ │ newItems will be REMOVED (data + metadata + linked index). │
633
+ │ Passing a partial list will wipe out everything else. │
634
+ │ │
635
+ │ newItems = [ │
636
+ │ { _id: "s1", name: "Krajnik", address: "Ljubljana" }, ← unchanged │
637
+ │ { _id: "s2", name: "Novak", address: "Celje" }, ← updated │
638
+ │ { _id: "s3", name: "Horvat", address: "Koper" }, ← added │
639
+ │ // s4 from previous state is missing → removed │
640
+ │ ] │
641
+ │ │
642
+ │ Behavior: │
643
+ │ 1. Items in collection but absent from newItems → removed │
644
+ │ (data + metadata + primaryToLinked all cleaned) │
645
+ │ 2. Items in both → tokens compared; metadata.set only if changed │
646
+ │ 3. New items → tokenized, added to metadata + primaryToLinked │
647
+ │ 4. _deleted/_blocked items in newItems → removed from metadata │
648
+ │ (kept in data, same convention as updateSearchMetadata) │
649
+ │ │
650
+ │ Options: │
651
+ │ skipUpdateCheck: true → don't re-tokenize existing items (faster) │
652
+ │ data refs still get updated │
653
+ │ │
654
+ │ Returns: SyncResult │
655
+ │ { added, updated, removed, addedIds[], updatedIds[], removedIds[] } │
656
+ │ │
657
+ │ Single shared tokensMap is used across the whole snapshot for efficiency. │
658
+ │ │
659
+ └─────────────────────────────────────────────────────────────────────────────┘
660
+ ```
661
+
662
+ #### When to use which update method
663
+
664
+ | Scenario | Method |
665
+ |---------------------------------------------------|--------------------------------------------------------------|
666
+ | Full snapshot from server (periodic refresh) | `syncCollection(name, allItems)` |
667
+ | Known delta — add/update some items | `updateSearchMetadataBatch(name, changedItems)` |
668
+ | Known IDs to remove | `removeFromSearchMetadataBatch(name, ids)` |
669
+ | Mark item as soft-deleted but keep in data | `updateSearchMetadata(name, id, { ...item, _deleted: ... })` |
670
+ | Initial load / full reset | `loadCollection(name, items)` |
671
+
672
+ **Rule of thumb:** if you don't have the complete current state of the
673
+ collection, do NOT use `syncCollection` — use the batch methods instead.
674
+ They only touch the items you pass in and leave the rest alone.
675
+
623
676
  ### Clear Universe
624
677
 
625
678
  ```
@@ -1,4 +1,4 @@
1
- import type { Id, NumericSearchMetadata, SearchableData, SearchableObject, UniversalSearchOpts, CollectionConfig, UpdateResult, BatchUpdateResult } from '../types';
1
+ import type { Id, NumericSearchMetadata, SearchableData, SearchableObject, UniversalSearchOpts, CollectionConfig, UpdateResult, BatchUpdateResult, SyncResult, SyncOptions } from '../types';
2
2
  export type { UniversalSearchOpts, CollectionConfig, UpdateResult, BatchUpdateResult } from '../types';
3
3
  /**
4
4
  * SearchUniverse manages multiple collections with their metadata and relationships.
@@ -154,6 +154,49 @@ export declare class SearchUniverse {
154
154
  * @returns Number of items actually removed
155
155
  */
156
156
  removeFromSearchMetadataBatch(collectionName: string, ids: Id[]): number;
157
+ /**
158
+ * Syncs a collection against a **full snapshot** of items.
159
+ *
160
+ * **IMPORTANT:** `newItems` MUST contain the entire desired state of the
161
+ * collection, not a partial delta. Any item currently in the collection
162
+ * whose `_id` is missing from `newItems` will be **completely removed**
163
+ * (data + metadata + linked indexes). Passing a partial list will wipe
164
+ * out everything else.
165
+ *
166
+ * Compares `newItems` to the current collection state and applies the diff:
167
+ * - items absent from `newItems` → removed
168
+ * - new IDs in `newItems` → added
169
+ * - existing IDs with changed tokens → updated
170
+ * - items marked `_deleted` or `_blocked` → removed from metadata
171
+ * (kept in `collection.data` for reference — same convention as
172
+ * {@link updateSearchMetadata})
173
+ *
174
+ * **When to use which method:**
175
+ * - Full snapshot from server (periodic refresh): `syncCollection`
176
+ * - Known delta of changes: {@link updateSearchMetadataBatch}
177
+ * - Known IDs to delete: {@link removeFromSearchMetadataBatch}
178
+ *
179
+ * For partial delta updates, prefer the batch methods — they leave untouched
180
+ * items unaffected.
181
+ *
182
+ * @typeParam T - Type of items in the collection
183
+ * @param collectionName - Name of the collection
184
+ * @param newItems - **Full** new dataset (complete snapshot, not a delta)
185
+ * @param options - Optional sync options (e.g. `skipUpdateCheck` to avoid re-tokenizing existing items)
186
+ * @returns SyncResult with counts and IDs of added/updated/removed items
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * // Full refresh from server — pass ALL current products
191
+ * const fresh = await fetchAllProducts();
192
+ * const result = universe.syncCollection('products', fresh);
193
+ * // { added: 3, updated: 12, removed: 1, addedIds: [...], ... }
194
+ *
195
+ * // ⚠️ DO NOT do this — would delete all other products:
196
+ * // universe.syncCollection('products', [oneChangedProduct]);
197
+ * ```
198
+ */
199
+ syncCollection<T extends SearchableObject>(collectionName: string, newItems: T[], options?: SyncOptions): SyncResult;
157
200
  /**
158
201
  * Gets collection or throws an error if not found.
159
202
  *
@@ -1 +1 @@
1
- {"version":3,"file":"SearchUniverse.d.ts","sourceRoot":"","sources":["../../src/common/SearchUniverse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,EAAE,EACF,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAEhB,mBAAmB,EACnB,gBAAgB,EAChB,YAAY,EACZ,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAWlB,YAAY,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAcvG;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,WAAW,CAAiC;IAEpD;;;;;OAKG;IACH,kBAAkB,CAAC,CAAC,SAAS,gBAAgB,EAAE,CAAC,GAAG,OAAO,EACxD,IAAI,EAAE,MAAM,EACZ,MAAM,GAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAM,GAClC,IAAI;IAmBP;;;;;OAKG;IACH,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIpC;;;;OAIG;IACH,kBAAkB,IAAI,MAAM,EAAE;IAI9B;;;;;;OAMG;IACH,cAAc,CAAC,CAAC,SAAS,gBAAgB,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,IAAI;IAsC1E;;;;;;;OAOG;IACH,OAAO,CAAC,CAAC,SAAS,gBAAgB,EAAE,cAAc,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,GAAG,SAAS;IAKlF;;;;;;OAMG;IACH,QAAQ,CAAC,CAAC,SAAS,gBAAgB,EAAE,cAAc,EAAE,MAAM,GAAG,CAAC,EAAE;IAKjE;;;;;OAKG;IACH,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM;IAKjD;;;;;OAKG;IACH,WAAW,CAAC,cAAc,EAAE,MAAM,GAAG,qBAAqB;IAK1D;;;;;;OAMG;IACH,OAAO,CAAC,CAAC,SAAS,gBAAgB,EAAE,cAAc,EAAE,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC;IAK9E;;;;;;;OAOG;IACH,YAAY,CAAC,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,GAAG,EAAE,EAAE;IAQ/D;;;;;;;OAOG;IACH,cAAc,CAAC,CAAC,SAAS,gBAAgB,EACvC,oBAAoB,EAAE,MAAM,EAC5B,SAAS,EAAE,EAAE,GACZ,CAAC,EAAE;IAMN;;;;;;;;;;;;OAYG;IACH,oBAAoB,CAAC,CAAC,SAAS,gBAAgB,EAC7C,cAAc,EAAE,MAAM,EACtB,EAAE,EAAE,EAAE,EACN,IAAI,EAAE,CAAC,GACN,YAAY;IAoFf;;;;;;OAMG;IACH,yBAAyB,CAAC,CAAC,SAAS,gBAAgB,EAClD,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,CAAC,EAAE,GACT,iBAAiB;IA0BpB;;;;;;OAMG;IACH,wBAAwB,CAAC,cAAc,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO;IAmCjE;;;;;;OAMG;IACH,6BAA6B,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,MAAM;IAcxE;;;;;;;OAOG;IACH,OAAO,CAAC,oBAAoB;IAQ5B;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,iBAAiB,CACf,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAW,EAClB,IAAI,CAAC,EAAE,mBAAmB,GACzB,MAAM,CAAC,MAAM,EAAE,gBAAgB,EAAE,GAAG,KAAK,CAAC;QAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;QAAC,OAAO,EAAE,gBAAgB,CAAC;QAAC,SAAS,EAAE,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;IA6FlJ;;;;;;;;OAQG;IACH,KAAK,IAAI,IAAI;CAUd"}
1
+ {"version":3,"file":"SearchUniverse.d.ts","sourceRoot":"","sources":["../../src/common/SearchUniverse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,90 @@
1
+ import type { Id, NumericSearchMetadata, SearchableData, SearchableObject, SearchOpts } from "../../types";
2
+ /**
3
+ * Tokenizes a string preserving source order (no alphabetic sort).
4
+ *
5
+ * Mirrors {@link prepareStringForSearch} pipeline minus the trailing `.sort()`,
6
+ * which is required for any positional / order-aware scoring.
7
+ *
8
+ * NOTE: do NOT use these tokens for cry-search candidate retrieval; those APIs
9
+ * assume sorted lists for efficient binary search.
10
+ */
11
+ export declare function orderedTokenize(input: string): string[];
12
+ /**
13
+ * Result of a scored / ranked search.
14
+ */
15
+ export interface ScoredResult<T extends SearchableObject> {
16
+ /** Item id. */
17
+ id: Id;
18
+ /** The matched item. */
19
+ item: T;
20
+ /** Relevance score (higher = better). 100_000 for exact-key match. */
21
+ score: number;
22
+ }
23
+ /**
24
+ * Options controlling {@link searchAndRank} scoring and retrieval.
25
+ */
26
+ export interface SearchAndRankOptions<T extends SearchableObject> {
27
+ /**
28
+ * Extracts the primary display text used for positional scoring.
29
+ * Typically the human-readable label (`item => item.naziv`).
30
+ */
31
+ primaryTextFn: (item: T) => string | undefined;
32
+ /**
33
+ * Optional: extracts values that must match the raw query string exactly
34
+ * for a top-priority boost (e.g. crtnaKoda for invoice items).
35
+ * Returns score 100_000 on exact equality.
36
+ */
37
+ exactKeysFn?: (item: T) => readonly string[] | undefined;
38
+ /** Maximum number of ranked results to return. Default: Infinity. */
39
+ limit?: number;
40
+ /** Underlying search options passed to candidate retrieval. */
41
+ opts?: SearchOpts;
42
+ }
43
+ /**
44
+ * Computes a relevance score for one item against a query.
45
+ *
46
+ * Scoring components (additive):
47
+ * - **+100_000** if the raw `query` equals any `exactKeys` value (e.g. crtnaKoda).
48
+ * - **+50** base for any subsequence-prefix match of query tokens in primary text.
49
+ * - **+100** if ALL query tokens match (in order) in primary text.
50
+ * - **+50** if first matched position is 0 (primary text starts with first query token).
51
+ * - **+80** if matched positions form a contiguous prefix `[0..k-1]` (perfect prefix).
52
+ * - **+30 * (matchedCount / span)** density bonus (rewards contiguous matches).
53
+ * - **-0.1 * primaryText.length** length penalty (weak tiebreaker — shorter wins).
54
+ *
55
+ * Returns **1** if the item was retrieved as a candidate but its primary text
56
+ * contains no query token (match came from a secondary field).
57
+ *
58
+ * Returns **0** if the query is empty.
59
+ */
60
+ export declare function scoreOne<T extends SearchableObject>(item: T, query: string, qTokens: string[], primaryTextFn: (item: T) => string | undefined, exactKeys?: readonly string[] | undefined): number;
61
+ /**
62
+ * Searches with positional relevance scoring and returns ranked results.
63
+ *
64
+ * Combines candidate retrieval ({@link searchInDataReturnIdsNumeric}, sorted-token
65
+ * AND-match) with a positional scoring layer that rewards in-order matches,
66
+ * first-word matches, perfect prefixes, dense (contiguous) matches, and shorter
67
+ * primary text.
68
+ *
69
+ * Results are sorted by score descending, with a stable tiebreaker on
70
+ * locale-aware primary-text comparison.
71
+ *
72
+ * @typeParam T - Item type (must extend SearchableObject)
73
+ * @param query - Raw query string (tokenized internally)
74
+ * @param data - SearchableData built via {@link arrayToSearchableData}
75
+ * @param metadata - NumericSearchMetadata built via {@link createSearchArrayMetadata}
76
+ * @param options - Scoring + retrieval options
77
+ * @returns Array of `{ id, item, score }` ordered by score descending
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const results = searchAndRank("pot mat", data, meta, {
82
+ * primaryTextFn: (a) => a.naziv,
83
+ * exactKeysFn: (a) => a.crtneKode,
84
+ * limit: 20,
85
+ * });
86
+ * // results[0].item.naziv === "POTROŠNI MATERIAL"
87
+ * ```
88
+ */
89
+ export declare function searchAndRankNumeric<T extends SearchableObject>(query: string, data: SearchableData<T>, metadata: NumericSearchMetadata, options: SearchAndRankOptions<T>): ScoredResult<T>[];
90
+ //# sourceMappingURL=searchAndRank.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchAndRank.d.ts","sourceRoot":"","sources":["../../../src/common/numeric/searchAndRank.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,EAAE,EACF,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACX,MAAM,aAAa,CAAC;AAOrB;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAKvD;AACD;;GAEG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,gBAAgB;IACtD,eAAe;IACf,EAAE,EAAE,EAAE,CAAC;IACP,wBAAwB;IACxB,IAAI,EAAE,CAAC,CAAC;IACR,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB,CAAC,CAAC,SAAS,gBAAgB;IAC9D;;;OAGG;IACH,aAAa,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAAG,SAAS,CAAC;IAC/C;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACzD,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AACD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,gBAAgB,EACjD,IAAI,EAAE,CAAC,EACP,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,EACjB,aAAa,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAAG,SAAS,EAC9C,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACxC,MAAM,CAyCR;AACD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,gBAAgB,EAC7D,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,QAAQ,EAAE,qBAAqB,EAC/B,OAAO,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAC/B,YAAY,CAAC,CAAC,CAAC,EAAE,CA0BnB"}
package/dist/index.cjs CHANGED
@@ -43,6 +43,9 @@ __export(exports_src, {
43
43
  searchInDataReturnObjects: () => searchInDataReturnObjectsNumeric,
44
44
  searchInDataReturnIds: () => searchInDataReturnIdsNumeric,
45
45
  searchInData: () => searchInDataReturnIdsNumeric,
46
+ searchAndRankNumeric: () => searchAndRankNumeric,
47
+ searchAndRank: () => searchAndRankNumeric,
48
+ scoreOne: () => scoreOne,
46
49
  sanitiseString: () => sanitiseString,
47
50
  resetTokenRegistry: () => resetTokenRegistry,
48
51
  removePrimaryItem: () => removePrimaryItemNumeric,
@@ -54,6 +57,7 @@ __export(exports_src, {
54
57
  prepareStringForSearch: () => prepareStringForSearch,
55
58
  prepareQueryTokens: () => prepareQueryTokens,
56
59
  prepareObjectForSearch: () => prepareObjectForSearchNumeric,
60
+ orderedTokenize: () => orderedTokenize,
57
61
  normalizeDates: () => normalizeDates,
58
62
  matchTokenInSortedListNumeric: () => matchTokenInSortedListNumeric,
59
63
  matchPreparedTokenInSortedList: () => matchPreparedTokenInSortedList,
@@ -1331,7 +1335,93 @@ function syncLinkedItemsForPrimaryNumeric(linked, primaryId, newLinkedItems, for
1331
1335
  clearTokensMap(tokensMap);
1332
1336
  return result;
1333
1337
  }
1338
+ // src/common/numeric/searchAndRank.ts
1339
+ function orderedTokenize(input) {
1340
+ return tokenize(sanitiseString(preprocessString(normalizeDates(input))), DEFAULT_TOKENIZE_CONFIG);
1341
+ }
1342
+ function scoreOne(item, query, qTokens, primaryTextFn, exactKeys) {
1343
+ if (exactKeys && exactKeys.length > 0) {
1344
+ for (let i = 0;i < exactKeys.length; i++) {
1345
+ if (exactKeys[i] === query)
1346
+ return 1e5;
1347
+ }
1348
+ }
1349
+ if (qTokens.length === 0)
1350
+ return 0;
1351
+ const primaryText = primaryTextFn(item) || "";
1352
+ const itemTokens = orderedTokenize(primaryText);
1353
+ if (itemTokens.length === 0)
1354
+ return 1;
1355
+ let qi = 0;
1356
+ const positions = [];
1357
+ for (let i = 0;i < itemTokens.length; i++) {
1358
+ if (qi < qTokens.length && itemTokens[i].startsWith(qTokens[qi])) {
1359
+ positions.push(i);
1360
+ qi++;
1361
+ }
1362
+ }
1363
+ if (qi === 0)
1364
+ return 1;
1365
+ let score = 50;
1366
+ if (qi === qTokens.length)
1367
+ score += 100;
1368
+ if (positions[0] === 0)
1369
+ score += 50;
1370
+ let perfect = qi === qTokens.length;
1371
+ if (perfect) {
1372
+ for (let i = 0;i < positions.length; i++) {
1373
+ if (positions[i] !== i) {
1374
+ perfect = false;
1375
+ break;
1376
+ }
1377
+ }
1378
+ }
1379
+ if (perfect)
1380
+ score += 80;
1381
+ if (positions.length >= 2) {
1382
+ const span = positions[positions.length - 1] - positions[0] + 1;
1383
+ score += positions.length / span * 30;
1384
+ }
1385
+ score -= primaryText.length * 0.1;
1386
+ return score;
1387
+ }
1388
+ function searchAndRankNumeric(query, data, metadata, options) {
1389
+ const { primaryTextFn, exactKeysFn, limit, opts } = options;
1390
+ const ids = searchInDataReturnIdsNumeric(query, metadata, opts);
1391
+ if (ids.length === 0)
1392
+ return [];
1393
+ const qTokens = orderedTokenize(query);
1394
+ const scored = [];
1395
+ for (const id of ids) {
1396
+ const item = data.get(id);
1397
+ if (!item)
1398
+ continue;
1399
+ const exactKeys = exactKeysFn?.(item);
1400
+ const score = scoreOne(item, query, qTokens, primaryTextFn, exactKeys);
1401
+ scored.push({ id, item, score });
1402
+ }
1403
+ scored.sort((x, y) => {
1404
+ if (y.score !== x.score)
1405
+ return y.score - x.score;
1406
+ const xText = primaryTextFn(x.item) || "";
1407
+ const yText = primaryTextFn(y.item) || "";
1408
+ return xText.localeCompare(yText);
1409
+ });
1410
+ if (limit === undefined || limit === Infinity || limit >= scored.length)
1411
+ return scored;
1412
+ return scored.slice(0, limit);
1413
+ }
1334
1414
  // src/common/SearchUniverse.ts
1415
+ function tokensEqual3(a, b) {
1416
+ if (a.length !== b.length)
1417
+ return false;
1418
+ for (let i = 0;i < a.length; i++) {
1419
+ if (a[i] !== b[i])
1420
+ return false;
1421
+ }
1422
+ return true;
1423
+ }
1424
+
1335
1425
  class SearchUniverse {
1336
1426
  collections = new Map;
1337
1427
  registerCollection(name, config = {}) {
@@ -1535,6 +1625,113 @@ class SearchUniverse {
1535
1625
  }
1536
1626
  return removed;
1537
1627
  }
1628
+ syncCollection(collectionName, newItems, options) {
1629
+ const collection = this.getCollectionOrThrow(collectionName);
1630
+ const result = {
1631
+ added: 0,
1632
+ updated: 0,
1633
+ removed: 0,
1634
+ addedIds: [],
1635
+ updatedIds: [],
1636
+ removedIds: []
1637
+ };
1638
+ const skipUpdateCheck = options?.skipUpdateCheck ?? false;
1639
+ const fkGetter = collection.config.linkedTo ? collection.config.linkedTo.foreignKeyGetter : undefined;
1640
+ const newIds = new Set;
1641
+ for (const item of newItems)
1642
+ newIds.add(item._id);
1643
+ const toRemove = [];
1644
+ for (const id of collection.data.keys()) {
1645
+ if (!newIds.has(id))
1646
+ toRemove.push(id);
1647
+ }
1648
+ for (const id of toRemove) {
1649
+ const existing = collection.data.get(id);
1650
+ if (fkGetter && collection.primaryToLinked) {
1651
+ const fk = fkGetter(existing);
1652
+ if (fk) {
1653
+ const list = collection.primaryToLinked.get(fk);
1654
+ if (list) {
1655
+ const idx = list.indexOf(id);
1656
+ if (idx !== -1) {
1657
+ list.splice(idx, 1);
1658
+ if (list.length === 0)
1659
+ collection.primaryToLinked.delete(fk);
1660
+ }
1661
+ }
1662
+ }
1663
+ }
1664
+ const hadMetadata = collection.metadata.has(id);
1665
+ collection.data.delete(id);
1666
+ collection.metadata.delete(id);
1667
+ if (hadMetadata) {
1668
+ result.removed++;
1669
+ result.removedIds.push(id);
1670
+ }
1671
+ }
1672
+ const tokensMap = createTokensMap();
1673
+ try {
1674
+ for (const item of newItems) {
1675
+ const id = item._id;
1676
+ const existing = collection.data.get(id);
1677
+ const hadMetadata = collection.metadata.has(id);
1678
+ const shouldTokenize = !existing || !skipUpdateCheck;
1679
+ let newTokens;
1680
+ if (shouldTokenize) {
1681
+ newTokens = prepareObjectForSearchNumeric(item, collection.config.spec, tokensMap);
1682
+ }
1683
+ if (fkGetter && collection.primaryToLinked) {
1684
+ const newFk = fkGetter(item);
1685
+ const oldFk = existing ? fkGetter(existing) : undefined;
1686
+ const wasInIndex = hadMetadata;
1687
+ const shouldBeInIndex = !!newFk && !item._deleted && !item._blocked;
1688
+ if (oldFk !== newFk || wasInIndex !== shouldBeInIndex) {
1689
+ if (oldFk && wasInIndex) {
1690
+ const list = collection.primaryToLinked.get(oldFk);
1691
+ if (list) {
1692
+ const idx = list.indexOf(id);
1693
+ if (idx !== -1) {
1694
+ list.splice(idx, 1);
1695
+ if (list.length === 0)
1696
+ collection.primaryToLinked.delete(oldFk);
1697
+ }
1698
+ }
1699
+ }
1700
+ if (shouldBeInIndex && newFk) {
1701
+ const list = collection.primaryToLinked.get(newFk) || [];
1702
+ if (!list.includes(id))
1703
+ list.push(id);
1704
+ collection.primaryToLinked.set(newFk, list);
1705
+ }
1706
+ }
1707
+ }
1708
+ collection.data.set(id, item);
1709
+ if (!shouldTokenize)
1710
+ continue;
1711
+ if (newTokens === undefined) {
1712
+ if (hadMetadata) {
1713
+ collection.metadata.delete(id);
1714
+ result.removed++;
1715
+ result.removedIds.push(id);
1716
+ }
1717
+ } else if (!hadMetadata) {
1718
+ collection.metadata.set(id, newTokens);
1719
+ result.added++;
1720
+ result.addedIds.push(id);
1721
+ } else {
1722
+ const existingTokens = collection.metadata.get(id);
1723
+ if (!tokensEqual3(existingTokens, newTokens)) {
1724
+ collection.metadata.set(id, newTokens);
1725
+ result.updated++;
1726
+ result.updatedIds.push(id);
1727
+ }
1728
+ }
1729
+ }
1730
+ } finally {
1731
+ clearTokensMap(tokensMap);
1732
+ }
1733
+ return result;
1734
+ }
1538
1735
  getCollectionOrThrow(name) {
1539
1736
  const collection = this.collections.get(name);
1540
1737
  if (!collection) {
package/dist/index.d.cts CHANGED
@@ -22,5 +22,6 @@ export { syncSearchArrayMetadataNumeric as syncSearchArrayMetadata, syncSearchAr
22
22
  export { upsertPrimaryItemNumeric as upsertPrimaryItem, removePrimaryItemNumeric as removePrimaryItem, upsertLinkedItemNumeric as upsertLinkedItem, removeLinkedItemNumeric as removeLinkedItem, syncLinkedItemsForPrimaryNumeric as syncLinkedItemsForPrimary, } from './common/numeric/updateSearchLinkedMetadata';
23
23
  export { matchTokenInSortedListNumeric, allTokensMatchNumeric, binarySearchUint32, prepareQueryTokens, matchPreparedTokenInSortedList, allPreparedTokensMatch, type PreparedQueryToken, } from './utils/matchTokenNumeric';
24
24
  export { isDeletedOrBlocked } from './common/string/createSearchArrayMetadata';
25
+ export { searchAndRankNumeric as searchAndRank, searchAndRankNumeric, scoreOne, orderedTokenize, type ScoredResult, type SearchAndRankOptions, } from './common/numeric/searchAndRank';
25
26
  export { SearchUniverse } from './common/SearchUniverse';
26
27
  //# sourceMappingURL=index.d.ts.map
package/dist/index.d.ts CHANGED
@@ -22,5 +22,6 @@ export { syncSearchArrayMetadataNumeric as syncSearchArrayMetadata, syncSearchAr
22
22
  export { upsertPrimaryItemNumeric as upsertPrimaryItem, removePrimaryItemNumeric as removePrimaryItem, upsertLinkedItemNumeric as upsertLinkedItem, removeLinkedItemNumeric as removeLinkedItem, syncLinkedItemsForPrimaryNumeric as syncLinkedItemsForPrimary, } from './common/numeric/updateSearchLinkedMetadata';
23
23
  export { matchTokenInSortedListNumeric, allTokensMatchNumeric, binarySearchUint32, prepareQueryTokens, matchPreparedTokenInSortedList, allPreparedTokensMatch, type PreparedQueryToken, } from './utils/matchTokenNumeric';
24
24
  export { isDeletedOrBlocked } from './common/string/createSearchArrayMetadata';
25
+ export { searchAndRankNumeric as searchAndRank, searchAndRankNumeric, scoreOne, orderedTokenize, type ScoredResult, type SearchAndRankOptions, } from './common/numeric/searchAndRank';
25
26
  export { SearchUniverse } from './common/SearchUniverse';
26
27
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,YAAY,EAEV,EAAE,EACF,WAAW,EACX,qBAAqB,EACrB,cAAc,EAEd,YAAY,EACZ,sBAAsB,EACtB,qBAAqB,EAErB,gBAAgB,EAChB,SAAS,EACT,oBAAoB,EAEpB,UAAU,EAEV,oBAAoB,EACpB,4BAA4B,EAC5B,2BAA2B,EAC3B,kBAAkB,EAClB,0BAA0B,EAE1B,oBAAoB,EAEpB,UAAU,EACV,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,6BAA6B,EAE7B,mBAAmB,EACnB,gBAAgB,EAChB,YAAY,EACZ,iBAAiB,EAEjB,cAAc,IAAI,oBAAoB,EACtC,mBAAmB,EACnB,cAAc,EACd,kBAAkB,GACnB,MAAM,SAAS,CAAC;AAGjB,YAAY,EAAE,qBAAqB,IAAI,cAAc,EAAE,MAAM,SAAS,CAAC;AAGvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAG9C,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAKvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAKxD,OAAO,EACL,MAAM,EACN,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,eAAe,EACf,cAAc,EACd,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAO/B,OAAO,EACL,gCAAgC,IAAI,yBAAyB,EAC7D,6BAA6B,IAAI,sBAAsB,EACvD,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,aAAa,EACb,eAAe,GAChB,MAAM,4CAA4C,CAAC;AAEpD,OAAO,EACL,iCAAiC,IAAI,0BAA0B,EAC/D,KAAK,mCAAmC,GACzC,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,2BAA2B,IAAI,oBAAoB,EACnD,gCAAgC,IAAI,yBAAyB,EAC7D,+BAA+B,IAAI,wBAAwB,EAC3D,oCAAoC,IAAI,6BAA6B,GACtE,MAAM,uCAAuC,CAAC;AAG/C,OAAO,EACL,4BAA4B,IAAI,YAAY,EAC5C,4BAA4B,IAAI,qBAAqB,EACrD,gCAAgC,IAAI,yBAAyB,EAC7D,qCAAqC,IAAI,qBAAqB,GAC/D,MAAM,+BAA+B,CAAC;AAGvC,OAAO,EACL,yBAAyB,EACzB,+BAA+B,GAChC,MAAM,qCAAqC,CAAC;AAG7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AAGvE,OAAO,EACL,8BAA8B,IAAI,uBAAuB,EACzD,0CAA0C,IAAI,mCAAmC,GAClF,MAAM,0CAA0C,CAAC;AAGlD,OAAO,EACL,wBAAwB,IAAI,iBAAiB,EAC7C,wBAAwB,IAAI,iBAAiB,EAC7C,uBAAuB,IAAI,gBAAgB,EAC3C,uBAAuB,IAAI,gBAAgB,EAC3C,gCAAgC,IAAI,yBAAyB,GAC9D,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,6BAA6B,EAC7B,qBAAqB,EACrB,kBAAkB,EAElB,kBAAkB,EAClB,8BAA8B,EAC9B,sBAAsB,EACtB,KAAK,kBAAkB,GACxB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAAE,kBAAkB,EAAE,MAAM,2CAA2C,CAAC;AAK/E,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1246,7 +1246,93 @@ function syncLinkedItemsForPrimaryNumeric(linked, primaryId, newLinkedItems, for
1246
1246
  clearTokensMap(tokensMap);
1247
1247
  return result;
1248
1248
  }
1249
+ // src/common/numeric/searchAndRank.ts
1250
+ function orderedTokenize(input) {
1251
+ return tokenize(sanitiseString(preprocessString(normalizeDates(input))), DEFAULT_TOKENIZE_CONFIG);
1252
+ }
1253
+ function scoreOne(item, query, qTokens, primaryTextFn, exactKeys) {
1254
+ if (exactKeys && exactKeys.length > 0) {
1255
+ for (let i = 0;i < exactKeys.length; i++) {
1256
+ if (exactKeys[i] === query)
1257
+ return 1e5;
1258
+ }
1259
+ }
1260
+ if (qTokens.length === 0)
1261
+ return 0;
1262
+ const primaryText = primaryTextFn(item) || "";
1263
+ const itemTokens = orderedTokenize(primaryText);
1264
+ if (itemTokens.length === 0)
1265
+ return 1;
1266
+ let qi = 0;
1267
+ const positions = [];
1268
+ for (let i = 0;i < itemTokens.length; i++) {
1269
+ if (qi < qTokens.length && itemTokens[i].startsWith(qTokens[qi])) {
1270
+ positions.push(i);
1271
+ qi++;
1272
+ }
1273
+ }
1274
+ if (qi === 0)
1275
+ return 1;
1276
+ let score = 50;
1277
+ if (qi === qTokens.length)
1278
+ score += 100;
1279
+ if (positions[0] === 0)
1280
+ score += 50;
1281
+ let perfect = qi === qTokens.length;
1282
+ if (perfect) {
1283
+ for (let i = 0;i < positions.length; i++) {
1284
+ if (positions[i] !== i) {
1285
+ perfect = false;
1286
+ break;
1287
+ }
1288
+ }
1289
+ }
1290
+ if (perfect)
1291
+ score += 80;
1292
+ if (positions.length >= 2) {
1293
+ const span = positions[positions.length - 1] - positions[0] + 1;
1294
+ score += positions.length / span * 30;
1295
+ }
1296
+ score -= primaryText.length * 0.1;
1297
+ return score;
1298
+ }
1299
+ function searchAndRankNumeric(query, data, metadata, options) {
1300
+ const { primaryTextFn, exactKeysFn, limit, opts } = options;
1301
+ const ids = searchInDataReturnIdsNumeric(query, metadata, opts);
1302
+ if (ids.length === 0)
1303
+ return [];
1304
+ const qTokens = orderedTokenize(query);
1305
+ const scored = [];
1306
+ for (const id of ids) {
1307
+ const item = data.get(id);
1308
+ if (!item)
1309
+ continue;
1310
+ const exactKeys = exactKeysFn?.(item);
1311
+ const score = scoreOne(item, query, qTokens, primaryTextFn, exactKeys);
1312
+ scored.push({ id, item, score });
1313
+ }
1314
+ scored.sort((x, y) => {
1315
+ if (y.score !== x.score)
1316
+ return y.score - x.score;
1317
+ const xText = primaryTextFn(x.item) || "";
1318
+ const yText = primaryTextFn(y.item) || "";
1319
+ return xText.localeCompare(yText);
1320
+ });
1321
+ if (limit === undefined || limit === Infinity || limit >= scored.length)
1322
+ return scored;
1323
+ return scored.slice(0, limit);
1324
+ }
1249
1325
  // src/common/SearchUniverse.ts
1326
+ function tokensEqual3(a, b) {
1327
+ if (a.length !== b.length)
1328
+ return false;
1329
+ for (let i = 0;i < a.length; i++) {
1330
+ if (a[i] !== b[i])
1331
+ return false;
1332
+ }
1333
+ return true;
1334
+ }
1335
+
1250
1336
  class SearchUniverse {
1251
1337
  collections = new Map;
1252
1338
  registerCollection(name, config = {}) {
@@ -1450,6 +1536,113 @@ class SearchUniverse {
1450
1536
  }
1451
1537
  return removed;
1452
1538
  }
1539
+ syncCollection(collectionName, newItems, options) {
1540
+ const collection = this.getCollectionOrThrow(collectionName);
1541
+ const result = {
1542
+ added: 0,
1543
+ updated: 0,
1544
+ removed: 0,
1545
+ addedIds: [],
1546
+ updatedIds: [],
1547
+ removedIds: []
1548
+ };
1549
+ const skipUpdateCheck = options?.skipUpdateCheck ?? false;
1550
+ const fkGetter = collection.config.linkedTo ? collection.config.linkedTo.foreignKeyGetter : undefined;
1551
+ const newIds = new Set;
1552
+ for (const item of newItems)
1553
+ newIds.add(item._id);
1554
+ const toRemove = [];
1555
+ for (const id of collection.data.keys()) {
1556
+ if (!newIds.has(id))
1557
+ toRemove.push(id);
1558
+ }
1559
+ for (const id of toRemove) {
1560
+ const existing = collection.data.get(id);
1561
+ if (fkGetter && collection.primaryToLinked) {
1562
+ const fk = fkGetter(existing);
1563
+ if (fk) {
1564
+ const list = collection.primaryToLinked.get(fk);
1565
+ if (list) {
1566
+ const idx = list.indexOf(id);
1567
+ if (idx !== -1) {
1568
+ list.splice(idx, 1);
1569
+ if (list.length === 0)
1570
+ collection.primaryToLinked.delete(fk);
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ const hadMetadata = collection.metadata.has(id);
1576
+ collection.data.delete(id);
1577
+ collection.metadata.delete(id);
1578
+ if (hadMetadata) {
1579
+ result.removed++;
1580
+ result.removedIds.push(id);
1581
+ }
1582
+ }
1583
+ const tokensMap = createTokensMap();
1584
+ try {
1585
+ for (const item of newItems) {
1586
+ const id = item._id;
1587
+ const existing = collection.data.get(id);
1588
+ const hadMetadata = collection.metadata.has(id);
1589
+ const shouldTokenize = !existing || !skipUpdateCheck;
1590
+ let newTokens;
1591
+ if (shouldTokenize) {
1592
+ newTokens = prepareObjectForSearchNumeric(item, collection.config.spec, tokensMap);
1593
+ }
1594
+ if (fkGetter && collection.primaryToLinked) {
1595
+ const newFk = fkGetter(item);
1596
+ const oldFk = existing ? fkGetter(existing) : undefined;
1597
+ const wasInIndex = hadMetadata;
1598
+ const shouldBeInIndex = !!newFk && !item._deleted && !item._blocked;
1599
+ if (oldFk !== newFk || wasInIndex !== shouldBeInIndex) {
1600
+ if (oldFk && wasInIndex) {
1601
+ const list = collection.primaryToLinked.get(oldFk);
1602
+ if (list) {
1603
+ const idx = list.indexOf(id);
1604
+ if (idx !== -1) {
1605
+ list.splice(idx, 1);
1606
+ if (list.length === 0)
1607
+ collection.primaryToLinked.delete(oldFk);
1608
+ }
1609
+ }
1610
+ }
1611
+ if (shouldBeInIndex && newFk) {
1612
+ const list = collection.primaryToLinked.get(newFk) || [];
1613
+ if (!list.includes(id))
1614
+ list.push(id);
1615
+ collection.primaryToLinked.set(newFk, list);
1616
+ }
1617
+ }
1618
+ }
1619
+ collection.data.set(id, item);
1620
+ if (!shouldTokenize)
1621
+ continue;
1622
+ if (newTokens === undefined) {
1623
+ if (hadMetadata) {
1624
+ collection.metadata.delete(id);
1625
+ result.removed++;
1626
+ result.removedIds.push(id);
1627
+ }
1628
+ } else if (!hadMetadata) {
1629
+ collection.metadata.set(id, newTokens);
1630
+ result.added++;
1631
+ result.addedIds.push(id);
1632
+ } else {
1633
+ const existingTokens = collection.metadata.get(id);
1634
+ if (!tokensEqual3(existingTokens, newTokens)) {
1635
+ collection.metadata.set(id, newTokens);
1636
+ result.updated++;
1637
+ result.updatedIds.push(id);
1638
+ }
1639
+ }
1640
+ }
1641
+ } finally {
1642
+ clearTokensMap(tokensMap);
1643
+ }
1644
+ return result;
1645
+ }
1453
1646
  getCollectionOrThrow(name) {
1454
1647
  const collection = this.collections.get(name);
1455
1648
  if (!collection) {
@@ -1541,6 +1734,9 @@ export {
1541
1734
  searchInDataReturnObjectsNumeric as searchInDataReturnObjects,
1542
1735
  searchInDataReturnIdsNumeric as searchInDataReturnIds,
1543
1736
  searchInDataReturnIdsNumeric as searchInData,
1737
+ searchAndRankNumeric,
1738
+ searchAndRankNumeric as searchAndRank,
1739
+ scoreOne,
1544
1740
  sanitiseString,
1545
1741
  resetTokenRegistry,
1546
1742
  removePrimaryItemNumeric as removePrimaryItem,
@@ -1552,6 +1748,7 @@ export {
1552
1748
  prepareStringForSearch,
1553
1749
  prepareQueryTokens,
1554
1750
  prepareObjectForSearchNumeric as prepareObjectForSearch,
1751
+ orderedTokenize,
1555
1752
  normalizeDates,
1556
1753
  matchTokenInSortedListNumeric,
1557
1754
  matchPreparedTokenInSortedList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-search",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "A fast, memory-efficient search library for large datasets with support for tokenized matching, linked collections, and per-field match modes.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",