@datakable/core 2.0.3 → 2.1.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.
Files changed (37) hide show
  1. package/dist/features/search/search-index.cjs +108 -81
  2. package/dist/features/search/search-index.cjs.map +1 -1
  3. package/dist/features/search/search-index.js +109 -82
  4. package/dist/features/search/search-index.js.map +1 -1
  5. package/dist/features/search/search.cjs +2 -1
  6. package/dist/features/search/search.cjs.map +1 -1
  7. package/dist/features/search/search.js +6 -2
  8. package/dist/features/search/search.js.map +1 -1
  9. package/dist/features/sorting/ranks.cjs +117 -50
  10. package/dist/features/sorting/ranks.cjs.map +1 -1
  11. package/dist/features/sorting/ranks.js +117 -50
  12. package/dist/features/sorting/ranks.js.map +1 -1
  13. package/dist/features/sorting/sorting.cjs +32 -8
  14. package/dist/features/sorting/sorting.cjs.map +1 -1
  15. package/dist/features/sorting/sorting.js +32 -8
  16. package/dist/features/sorting/sorting.js.map +1 -1
  17. package/dist/pipeline/column-cache.cjs +6 -2
  18. package/dist/pipeline/column-cache.cjs.map +1 -1
  19. package/dist/pipeline/column-cache.js +6 -2
  20. package/dist/pipeline/column-cache.js.map +1 -1
  21. package/dist/pipeline/release.cjs +34 -0
  22. package/dist/pipeline/release.cjs.map +1 -0
  23. package/dist/pipeline/release.js +8 -0
  24. package/dist/pipeline/release.js.map +1 -0
  25. package/dist/pipeline/runner.cjs +15 -1
  26. package/dist/pipeline/runner.cjs.map +1 -1
  27. package/dist/pipeline/runner.js +15 -1
  28. package/dist/pipeline/runner.js.map +1 -1
  29. package/dist/table/create-table.cjs +3 -0
  30. package/dist/table/create-table.cjs.map +1 -1
  31. package/dist/table/create-table.js +3 -0
  32. package/dist/table/create-table.js.map +1 -1
  33. package/dist/version.cjs +1 -1
  34. package/dist/version.cjs.map +1 -1
  35. package/dist/version.js +1 -1
  36. package/dist/version.js.map +1 -1
  37. package/package.json +3 -3
@@ -34,7 +34,7 @@ var import_row = require("../../pipeline/row.cjs");
34
34
  var import_capability = require("../../versioning/capability.cjs");
35
35
  var CHUNK = 4096;
36
36
  var CELLS = 16384;
37
- var hasExSearch = () => [import_ex_search.ExSearch.prototype.search, import_ex_search.ExSearch.prototype.setData, import_ex_search.Scorer.fuzzy].every(
37
+ var hasExSearch = () => [import_ex_search.ExSearch.prototype.search, import_ex_search.ExSearch.prototype.setData, import_ex_search.ExSearch.prototype.updateConfig].every(
38
38
  (f) => typeof f === "function"
39
39
  );
40
40
  function probeExSearch(emit) {
@@ -43,8 +43,8 @@ function probeExSearch(emit) {
43
43
  {
44
44
  feature: "search",
45
45
  dependency: "ex-search",
46
- capability: "ExSearch, Scorer.fuzzy",
47
- supported: "^1.1.1",
46
+ capability: "ExSearch (fuzzyMode: 'word')",
47
+ supported: "^1.2.0",
48
48
  fallback: "case-insensitive exact / prefix / substring matching (no fuzzy matching)"
49
49
  },
50
50
  emit
@@ -87,8 +87,8 @@ function* buildIndex(ctx, config, useExSearch) {
87
87
  const reads = [];
88
88
  for (const col of cols.list) {
89
89
  if (cols.byId.get(col.id) !== col) continue;
90
- const w = col.def.search;
91
- const weight = declared ? typeof w === "number" && w > 0 ? w : 0 : 1;
90
+ const w2 = col.def.search;
91
+ const weight = declared ? typeof w2 === "number" && w2 > 0 ? w2 : 0 : 1;
92
92
  if (weight === 0) continue;
93
93
  const path = col.format ? void 0 : col.path;
94
94
  let failed = false;
@@ -126,48 +126,101 @@ function* buildIndex(ctx, config, useExSearch) {
126
126
  yield;
127
127
  }
128
128
  if (keys.length === 0) return null;
129
+ const w = keys.map((k) => k.weight).join();
130
+ const hash = (r) => {
131
+ let a = 3735928559;
132
+ let b = 1103547991;
133
+ for (const read of reads) {
134
+ const v = read(r);
135
+ for (let j = 0; j <= v.length; j++) {
136
+ const c = v.charCodeAt(j) + 1;
137
+ a = Math.imul(a ^ c, 2654435761);
138
+ b = Math.imul(b ^ c, 1597334677);
139
+ }
140
+ }
141
+ a = Math.imul(a ^ a >>> 16, 2246822507) ^ Math.imul(b ^ b >>> 13, 3266489909);
142
+ b = Math.imul(b ^ b >>> 16, 2246822507) ^ Math.imul(a ^ a >>> 13, 3266489909);
143
+ return 4294967296 * (2097151 & b) + (a >>> 0);
144
+ };
129
145
  const fold = (v) => config.caseSensitive ? v : v.toLowerCase();
130
146
  const foldQuery = (q) => fold(q.normalize("NFC"));
131
147
  const at = (k, r) => fold(reads[k](r));
132
- return useExSearch ? exSearchIndex(ctx.data.length, keys, at, foldQuery, config) : containsIndex(keys, at, foldQuery, config);
133
- }
134
- function* chunked(rows, columns, matchChunk) {
135
- const size = Math.max(256, Math.floor(CELLS / columns));
136
- if (rows.length <= size) return rankHits(matchChunk(rows));
137
- const hits = [];
138
- for (let k = 0; k < rows.length; k += size) {
139
- for (const h of matchChunk(rows.subarray(k, k + size))) hits.push(h);
140
- yield;
141
- }
142
- return rankHits(hits);
148
+ const matchChunk = (useExSearch ? exSearchIndex : containsIndex)(keys, at, config);
149
+ const h = new Float64Array(n).fill(-1);
150
+ return {
151
+ *match(rows, query, memo) {
152
+ const q = foldQuery(query);
153
+ const prev = memo?.scores;
154
+ const old = prev?.q === query && prev.w === w ? prev : void 0;
155
+ const d = old ? old.h.length - n : 0;
156
+ const cut = !old && prev?.h === h && prev.w === w && matchChunk.narrows?.(foldQuery(prev.q), q);
157
+ const s = new Float64Array(n).fill(Number.NaN);
158
+ const size = Math.max(256, Math.floor(CELLS / keys.length));
159
+ const hits = [];
160
+ for (let k = 0; k < rows.length; k += size) {
161
+ if (k) yield;
162
+ const chunk = rows.subarray(k, k + size);
163
+ const miss = [];
164
+ for (const r of chunk) {
165
+ let x = h[r];
166
+ if (x < 0) {
167
+ x = hash(r);
168
+ h[r] = x;
169
+ }
170
+ s[r] = -1;
171
+ if (cut) {
172
+ if (prev.s[r] !== -1) miss.push(r);
173
+ continue;
174
+ }
175
+ if (!old) continue;
176
+ const j = old.h[r] === x ? r : old.h[r + d] === x ? r + d : -1;
177
+ const v = old.s[j];
178
+ if (v >= -1) {
179
+ s[r] = v;
180
+ if (v >= 0) hits.push({ i: r, score: v });
181
+ } else miss.push(r);
182
+ }
183
+ const todo = old || cut ? Uint32Array.from(miss) : chunk;
184
+ if (todo.length) {
185
+ for (const hit of matchChunk(todo, q)) {
186
+ s[hit.i] = hit.score;
187
+ hits.push(hit);
188
+ }
189
+ }
190
+ }
191
+ if (memo) memo.scores = { q: query, w, h, s };
192
+ return rankHits(hits);
193
+ }
194
+ };
143
195
  }
144
- function projection(size, keys, at) {
145
- let rows;
196
+ function projection(keys, at) {
197
+ let rows = new Uint32Array(0);
198
+ let held = [];
146
199
  const target = [];
147
- const length = () => rows ? rows.length : size;
148
- const item = (j) => {
149
- const r = rows ? rows[j] : j;
150
- const o = { i: r };
151
- for (let k = 0; k < keys.length; k++) o[keys[k].name] = at(k, r);
152
- return o;
153
- };
154
200
  const items = new Proxy(target, {
155
201
  get(t, p) {
156
- if (p === "length") return length();
157
- const j = (0, import_row.arrayIndex)(p, length());
158
- return j >= 0 ? item(j) : t[p];
202
+ if (p === "length") return rows.length;
203
+ const j = (0, import_row.arrayIndex)(p, rows.length);
204
+ if (j < 0) return t[p];
205
+ if (held[j]) return held[j];
206
+ const r = rows[j];
207
+ const o = { i: r };
208
+ for (let k = 0; k < keys.length; k++) o[keys[k].name] = at(k, r);
209
+ held[j] = o;
210
+ return o;
159
211
  },
160
- has: (t, p) => (0, import_row.arrayIndex)(p, length()) >= 0 || p in t
212
+ has: (t, p) => (0, import_row.arrayIndex)(p, rows.length) >= 0 || p in t
161
213
  });
162
214
  return {
163
215
  items,
164
- /** Shows `chunk` (row indices), or every row again (`undefined`). */
216
+ /** Shows `chunk` (row indices), each item built once until the next `show`. */
165
217
  show(chunk) {
166
218
  rows = chunk;
219
+ held = [];
167
220
  }
168
221
  };
169
222
  }
170
- function exSearchIndex(size, keys, at, fold, config) {
223
+ function exSearchIndex(keys, at, config) {
171
224
  const engine = new import_ex_search.ExSearch({
172
225
  keys,
173
226
  ...config,
@@ -176,60 +229,34 @@ function exSearchIndex(size, keys, at, fold, config) {
176
229
  // stable and avoids radix's score quantisation.
177
230
  sortAlgorithm: "tim"
178
231
  });
179
- const max = config.fuzzyMaxDistance;
180
- let q = "";
181
- const near = (k, r) => {
182
- const v = at(k, r);
183
- if (v.includes(q) || /\s/.test(q)) return v;
184
- let best = "";
185
- let top = 0;
186
- const words = v.split(/\s+/);
187
- for (const w of words[1] ? [v, ...words] : words) {
188
- const s = Math.abs(w.length - q.length) > max ? 0 : import_ex_search.Scorer.fuzzy(w, q, max, true);
189
- if (s > top) {
190
- best = w;
191
- top = s;
192
- }
193
- }
194
- return best;
195
- };
196
- const view = projection(size, keys, near);
232
+ const view = projection(keys, at);
197
233
  engine.setData(view.items);
198
- return {
199
- match(rows, query) {
200
- const folded = fold(query);
201
- return chunked(rows, keys.length, (chunk) => {
202
- q = folded;
203
- view.show(chunk);
204
- try {
205
- return engine.search(q);
206
- } finally {
207
- view.show(void 0);
208
- }
209
- });
210
- }
234
+ const match = (chunk, q) => {
235
+ engine.updateConfig({ fuzzyMode: !config.fuzzyMaxDistance || /\s/.test(q) ? "value" : "word" });
236
+ view.show(chunk);
237
+ const hits = engine.search(q);
238
+ engine.updateConfig({});
239
+ view.show(chunk);
240
+ return hits;
211
241
  };
242
+ match.narrows = (prev, next) => engine.canNarrow?.(prev, next);
243
+ return match;
212
244
  }
213
- function containsIndex(keys, at, fold, config) {
214
- return {
215
- match(rows, query) {
216
- const q = fold(query);
217
- return chunked(rows, keys.length, (chunk) => {
218
- const hits = [];
219
- for (let j = 0; j < chunk.length; j++) {
220
- const r = chunk[j];
221
- let best = 0;
222
- for (let k = 0; k < keys.length; k++) {
223
- const v = at(k, r);
224
- const raw = v === q ? 1 : v.startsWith(q) ? 0.8 : v.includes(q) ? 0.65 : 0;
225
- const w = keys[k].weight;
226
- if (raw * w > best) best = raw * w;
227
- }
228
- if (best > 0 && best >= config.threshold) hits.push({ i: r, score: best });
229
- }
230
- return hits;
231
- });
245
+ function containsIndex(keys, at, config) {
246
+ return (chunk, q) => {
247
+ const hits = [];
248
+ for (let j = 0; j < chunk.length; j++) {
249
+ const r = chunk[j];
250
+ let best = 0;
251
+ for (let k = 0; k < keys.length; k++) {
252
+ const v = at(k, r);
253
+ const raw = v === q ? 1 : v.startsWith(q) ? 0.8 : v.includes(q) ? 0.65 : 0;
254
+ const w = keys[k].weight;
255
+ if (raw * w > best) best = raw * w;
256
+ }
257
+ if (best > 0 && best >= config.threshold) hits.push({ i: r, score: best });
232
258
  }
259
+ return hits;
233
260
  };
234
261
  }
235
262
  function rankHits(hits) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/features/search/search-index.ts"],"sourcesContent":["import { ExSearch, Scorer } from 'ex-search'\nimport { getValue } from '../../column/get-value'\nimport '../../diagnostics/process-guard'\nimport type { Emit } from '../../diagnostics/types'\nimport type { RowModelContext } from '../../feature/types'\nimport { nullishOf, type SlicedFormatting } from '../../pipeline/column-cache'\nimport { resolveColumns } from '../../pipeline/columns'\nimport { arrayIndex } from '../../pipeline/row'\nimport { detectCapability, hasCapability } from '../../versioning/capability'\n\n/** Matcher settings (the client options of `search()`, defaults applied). */\nexport interface MatchConfig {\n readonly threshold: number\n readonly fuzzyMaxDistance: number\n readonly caseSensitive: boolean\n}\n\n/**\n * One projected row as ex-search reads it: `i` is the row index (results map back by it, never by\n * identity: ex-search returns copies), `c0`, `c1`, … the formatted values of the searchable\n * columns. Flat keys also sidestep ex-search's top-level-key limitation for nested paths. Items\n * are built per chunk while matching and dropped after it (§9.3 memory): none is kept.\n */\nexport type Item = Record<string, string | number> & { readonly i: number }\n\n/** A match: the row index and its relevance (0–1, weight applied). */\nexport interface Hit {\n readonly i: number\n readonly score: number\n}\n\n/** The matcher of one (data, columns) pair: references to column values, no per-row copy. */\nexport interface SearchIndex {\n /**\n * A time-sliced job (§9.1) matching `query` (trimmed, non-blank) against the rows `rows` (row\n * indices), in chunks: the hits in relevance order ({@link rankHits}).\n */\n match(rows: Uint32Array, query: string): Generator<undefined, Uint32Array, undefined>\n}\n\n/** Rows per chunk between two `yield`s of a scan (one value per row). */\nconst CHUNK = 4096\n\n/**\n * Cells (rows × searchable columns) matched per chunk between two `yield`s (#40): a table with\n * many searchable columns matches fewer rows per chunk, so a chunk stays a few milliseconds.\n */\nconst CELLS = 16_384\n\ninterface Key {\n readonly name: string\n readonly weight: number\n}\n\n/** The formatted value of row `r` of one searchable column. */\ntype Read = (r: number) => string\n\n// A throw (no `ExSearch`) counts as missing (hasCapability).\nconst hasExSearch = () =>\n [ExSearch.prototype.search, ExSearch.prototype.setData, Scorer.fuzzy].every(\n (f) => typeof f === 'function',\n )\n\n/** Feature-detects ex-search once (§8.5); `false` → the contains fallback. */\nexport function probeExSearch(emit: Emit): boolean {\n // DK_DEP_INCOMPATIBLE is a development check (§8.5); the fallback is not.\n return (process.env.NODE_ENV !== \"production\")\n ? detectCapability(\n hasExSearch,\n {\n feature: 'search',\n dependency: 'ex-search',\n capability: 'ExSearch, Scorer.fuzzy',\n supported: '^1.1.1',\n fallback: 'case-insensitive exact / prefix / substring matching (no fuzzy matching)',\n },\n emit,\n )\n : hasCapability(hasExSearch)\n}\n\n/**\n * A numeric column's formatted value without cached strings (#27): what the default formatter\n * shows, `String(v)`, or `''` for a `null` / `undefined` cell (stored as `NaN`, like a real `NaN`,\n * which shows `'NaN'`).\n */\nfunction numberReader(values: Float64Array): Read {\n const nullish = nullishOf(values)\n return (r) => {\n const v = values[r] as number\n return !Number.isNaN(v) ? String(v) : nullish?.[r] ? '' : 'NaN'\n }\n}\n\n/**\n * One pass over a column's values (`get(r)` per row, time-sliced by `CHUNK` rows): their text as\n * the default formatter shows a primitive (`String(v)`, `''` when nullish), read while matching\n * (#27: no formatted copy per row). `false` at the first object or function (a `Date`, an array):\n * the column cache formats those. `undefined` when a read throws. The values are read in NFC (#30),\n * normalised here, never per keystroke: the rows whose value is not NFC (rare: NFD input from\n * macOS, Thai or Vietnamese keyboards) keep their NFC form in a sparse map. Only values with a code\n * unit from U+0300 (combining marks and up) can change.\n */\nfunction* scan(\n size: number,\n get: (r: number) => unknown,\n): Generator<undefined, Read | false | undefined, undefined> {\n const over = new Map<number, string>()\n try {\n for (let r = 0; r < size; r++) {\n const v = get(r)\n if (v !== null && (typeof v === 'object' || typeof v === 'function')) return false\n if (typeof v === 'string' && /[\\u0300-\\uffff]/.test(v)) {\n const n = v.normalize('NFC')\n if (n !== v) over.set(r, n)\n }\n if (r % CHUNK === CHUNK - 1) yield\n }\n } catch {\n return undefined\n }\n const read: Read = (r) => {\n const v = get(r)\n return v === null || v === undefined ? '' : String(v)\n }\n return over.size ? (r) => over.get(r) ?? read(r) : read\n}\n\n/**\n * Builds the matcher over the formatted values of the searchable columns (what the user sees), once\n * per data + columns, as a time-sliced job (§9.1): one column read per slice. Searchable columns are\n * those with a `search` weight > 0. When no column declares `search`, the columns of primitives\n * (strings, numbers, booleans, nullable) are searchable at weight 1, and a column of dates, arrays\n * or other objects is not (#27: it would keep a formatted string per row; it opts in with a\n * weight; `DK_SEARCH_COLUMN_SKIPPED` in development). `null` when nothing is searchable. Nothing\n * is copied per row (§9.3 memory): a key column of primitives without `format` is read from the\n * rows and stringified while matching (no values array, #27); an accessor column of primitives\n * reads the column cache's values the same way; only a column with `format` or an opted-in object\n * column (dates, arrays) keeps its formatted strings in the column cache. A key column whose read\n * throws goes through the column cache too, which reports it. The case is folded per chunk while\n * matching.\n */\nexport function* buildIndex(\n ctx: RowModelContext,\n config: MatchConfig,\n useExSearch: boolean,\n): Generator<undefined, SearchIndex | null, undefined> {\n const cols = resolveColumns(ctx.columns)\n const declared = cols.list.some((c) => (c.def as { search?: unknown }).search !== undefined)\n const data = ctx.data\n const n = data.length\n const keys: Key[] = []\n const reads: Read[] = []\n for (const col of cols.list) {\n if (cols.byId.get(col.id) !== col) continue\n const w = (col.def as { search?: unknown }).search\n const weight = declared ? (typeof w === 'number' && w > 0 ? w : 0) : 1\n if (weight === 0) continue\n const path = col.format ? undefined : col.path\n let failed = false\n const onError = () => {\n failed = true\n }\n let read = path ? yield* scan(n, (r) => getValue(data[r], path, onError)) : undefined\n if (failed || (read === false && declared)) read = undefined\n if (read === undefined) {\n const values = ctx.getColumnValues(col.id)\n if (!values) continue\n yield // reading a column is a task of its own (#40)\n const numbers = values instanceof Float64Array\n if (numbers && !col.format) read = numberReader(values)\n else {\n // Zero-config: a column of objects is not searched, `format` or not (#27).\n const own =\n numbers || (declared && col.format) ? undefined : yield* scan(n, (r) => values[r])\n if (own === false && !declared) read = false\n else if (own && !col.format) read = own\n else {\n // Time-sliced in a table run (#40): dates or a heavy `format` are not one long task.\n const sliced = (ctx as RowModelContext & SlicedFormatting).formattedSliced\n const formatted = sliced ? yield* sliced(col.id) : ctx.getFormattedValues(col.id)\n if (!formatted) continue\n read = yield* scan(n, (r) => formatted[r])\n }\n }\n }\n if (!read) {\n if ((process.env.NODE_ENV !== \"production\") && read === false) {\n ctx.emit('DK_SEARCH_COLUMN_SKIPPED', { column: col.id, index: col.index })\n }\n continue\n }\n keys.push({ name: `c${keys.length}`, weight })\n reads.push(read)\n yield\n }\n if (keys.length === 0) return null\n // Values (NFC already) are folded per chunk while matching; the query is normalised as well, so\n // NFC and NFD spellings match each other like in the `contains` filter (#30).\n const fold = (v: string) => (config.caseSensitive ? v : v.toLowerCase())\n const foldQuery = (q: string) => fold(q.normalize('NFC'))\n const at = (k: number, r: number) => fold((reads[k] as Read)(r))\n return useExSearch\n ? exSearchIndex(ctx.data.length, keys, at, foldQuery, config)\n : containsIndex(keys, at, foldQuery, config)\n}\n\n/** Matches `rows` chunk by chunk (`score` decides alone, so chunks match like the whole). */\nfunction* chunked(\n rows: Uint32Array,\n columns: number,\n matchChunk: (chunk: Uint32Array) => Hit[],\n): Generator<undefined, Uint32Array, undefined> {\n const size = Math.max(256, Math.floor(CELLS / columns))\n if (rows.length <= size) return rankHits(matchChunk(rows))\n const hits: Hit[] = []\n for (let k = 0; k < rows.length; k += size) {\n for (const h of matchChunk(rows.subarray(k, k + size))) hits.push(h)\n yield\n }\n return rankHits(hits)\n}\n\n/**\n * The projection as ex-search reads it: an array (iteration, `length`, index reads) of `{ i, c0, … }`\n * items, each built when read and not kept. It shows every row of `data` (`size`), or one chunk of\n * rows while that chunk is matched; ex-search holds this one view (`setData` once).\n */\nfunction projection(size: number, keys: readonly Key[], at: (k: number, r: number) => string) {\n let rows: Uint32Array | undefined\n // Never written, and its own `length` stays 0: `length` is read through the trap below (setting\n // a large `length` would allocate a backing store).\n const target: Item[] = []\n const length = () => (rows ? rows.length : size)\n const item = (j: number): Item => {\n const r = rows ? (rows[j] as number) : j\n const o = { i: r } as Record<string, string | number>\n for (let k = 0; k < keys.length; k++) o[(keys[k] as Key).name] = at(k, r)\n return o as Item\n }\n const items = new Proxy(target, {\n get(t, p) {\n if (p === 'length') return length()\n const j = arrayIndex(p, length())\n return j >= 0 ? item(j) : t[p as keyof Item[]]\n },\n has: (t, p) => arrayIndex(p, length()) >= 0 || p in t,\n })\n return {\n items,\n /** Shows `chunk` (row indices), or every row again (`undefined`). */\n show(chunk: Uint32Array | undefined) {\n rows = chunk\n },\n }\n}\n\nfunction exSearchIndex(\n size: number,\n keys: readonly Key[],\n at: (k: number, r: number) => string,\n fold: (v: string) => string,\n config: MatchConfig,\n): SearchIndex {\n // Values and query are folded already (buildIndex).\n const engine = new ExSearch<Item>({\n keys: keys as Key[],\n ...config,\n caseSensitive: true,\n // DataKable re-ranks with exact scores and an index tie-break; tim keeps ex-search's own pass\n // stable and avoids radix's score quantisation.\n sortAlgorithm: 'tim',\n })\n // ex-search measures the edit distance to the whole value, so a typo of one word of a multi-word\n // cell never matched (#10). For a one-word query, a value without it is shown as the best fuzzy\n // match of itself and its words, or as '' (scores 0, as the value would). A value with the query\n // stays whole for the exact / prefix / substring scores. Only strings within `max` of the query's\n // length are measured (an edit distance is at least the length difference).\n const max = config.fuzzyMaxDistance\n let q = ''\n const near = (k: number, r: number) => {\n const v = at(k, r)\n if (v.includes(q) || /\\s/.test(q)) return v\n let best = ''\n let top = 0\n const words = v.split(/\\s+/)\n for (const w of words[1] ? [v, ...words] : words) {\n const s = Math.abs(w.length - q.length) > max ? 0 : Scorer.fuzzy(w, q, max, true)\n if (s > top) {\n best = w\n top = s\n }\n }\n return best\n }\n const view = projection(size, keys, near)\n engine.setData(view.items)\n return {\n match(rows, query) {\n const folded = fold(query)\n return chunked(rows, keys.length, (chunk) => {\n // Set per chunk, like the view: another job may have matched in between.\n q = folded\n view.show(chunk)\n try {\n // Results are copies of the items (`Omit<Item, …>` drops `i` from the type, not the value).\n return engine.search(q) as unknown as Hit[]\n } finally {\n view.show(undefined)\n }\n })\n },\n }\n}\n\n/** The fallback when ex-search is missing or incompatible: exact 1 · prefix 0.8 · substring 0.65. */\nfunction containsIndex(\n keys: readonly Key[],\n at: (k: number, r: number) => string,\n fold: (v: string) => string,\n config: MatchConfig,\n): SearchIndex {\n return {\n match(rows, query) {\n const q = fold(query)\n return chunked(rows, keys.length, (chunk) => {\n const hits: Hit[] = []\n for (let j = 0; j < chunk.length; j++) {\n const r = chunk[j] as number\n let best = 0\n for (let k = 0; k < keys.length; k++) {\n const v = at(k, r)\n const raw = v === q ? 1 : v.startsWith(q) ? 0.8 : v.includes(q) ? 0.65 : 0\n const w = (keys[k] as Key).weight\n if (raw * w > best) best = raw * w\n }\n if (best > 0 && best >= config.threshold) hits.push({ i: r, score: best })\n }\n return hits\n })\n },\n }\n}\n\n/**\n * Hits in relevance order (§6.3): score descending, ties by original row index. Enforced here so\n * the order never depends on the matcher's own ranking. Sorts `hits` (a fresh array) in place.\n */\nexport function rankHits(hits: Hit[]): Uint32Array {\n return Uint32Array.from(\n hits.sort((a, b) => b.score - a.score || a.i - b.i),\n (h) => h.i,\n )\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/features/search/search-index.ts"],"sourcesContent":["import { ExSearch } from 'ex-search'\nimport { getValue } from '../../column/get-value'\nimport '../../diagnostics/process-guard'\nimport type { Emit } from '../../diagnostics/types'\nimport type { RowModelContext } from '../../feature/types'\nimport { nullishOf, type SlicedFormatting } from '../../pipeline/column-cache'\nimport { resolveColumns } from '../../pipeline/columns'\nimport { arrayIndex } from '../../pipeline/row'\nimport { detectCapability, hasCapability } from '../../versioning/capability'\n\n/** Matcher settings (the client options of `search()`, defaults applied). */\nexport interface MatchConfig {\n readonly threshold: number\n readonly fuzzyMaxDistance: number\n readonly caseSensitive: boolean\n}\n\n/**\n * One projected row as ex-search reads it: `i` is the row index (results map back by it, never by\n * identity: ex-search returns copies), `c0`, `c1`, … the formatted values of the searchable\n * columns. Flat keys also sidestep ex-search's top-level-key limitation for nested paths. Items\n * are built per chunk while matching and dropped after it (§9.3 memory): none is kept.\n */\nexport type Item = Record<string, string | number> & { readonly i: number }\n\n/** A match: the row index and its relevance (0–1, weight applied). */\nexport interface Hit {\n readonly i: number\n readonly score: number\n}\n\n/**\n * The scores of the last match of a table (#95), per data row: `h` the 53-bit hashes of the rows'\n * searched texts (the index's, `-1`: not hashed), `s` its score (`-1`: no hit, `NaN`: not matched).\n * 16 bytes per row.\n */\nexport interface Scores {\n /** The query. */\n readonly q: string\n /** The weights of the searched columns, in key order. */\n readonly w: string\n readonly h: Float64Array\n readonly s: Float64Array\n}\n\n/** The matcher of one (data, columns) pair: references to column values, no per-row copy. */\nexport interface SearchIndex {\n /**\n * A time-sliced job (§9.1) matching `query` (trimmed, non-blank) against the rows `rows` (row\n * indices), in chunks: the hits in relevance order ({@link rankHits}). With `memo`, a row whose\n * searched texts hash like the same row of `memo.scores` (same position, or shifted by the\n * change in length: an insert or a delete) keeps its score and is not matched again (#95). On\n * the same data, a query that provably keeps no row the previous one dropped matches only the\n * previous hits (#96). `memo.scores` is then replaced by this match's.\n */\n match(\n rows: Uint32Array,\n query: string,\n memo?: { scores?: Scores },\n ): Generator<undefined, Uint32Array, undefined>\n}\n\n/** Rows per chunk between two `yield`s of a scan (one value per row). */\nconst CHUNK = 4096\n\n/**\n * Cells (rows × searchable columns) matched per chunk between two `yield`s (#40): a table with\n * many searchable columns matches fewer rows per chunk, so a chunk stays a few milliseconds.\n */\nconst CELLS = 16_384\n\ninterface Key {\n readonly name: string\n readonly weight: number\n}\n\n/** The formatted value of row `r` of one searchable column. */\ntype Read = (r: number) => string\n\n// A throw (no `ExSearch`) counts as missing (hasCapability).\nconst hasExSearch = () =>\n [ExSearch.prototype.search, ExSearch.prototype.setData, ExSearch.prototype.updateConfig].every(\n (f) => typeof f === 'function',\n )\n\n/** Feature-detects ex-search once (§8.5); `false` → the contains fallback. */\nexport function probeExSearch(emit: Emit): boolean {\n // DK_DEP_INCOMPATIBLE is a development check (§8.5); the fallback is not.\n return (process.env.NODE_ENV !== \"production\")\n ? detectCapability(\n hasExSearch,\n {\n feature: 'search',\n dependency: 'ex-search',\n capability: \"ExSearch (fuzzyMode: 'word')\",\n supported: '^1.2.0',\n fallback: 'case-insensitive exact / prefix / substring matching (no fuzzy matching)',\n },\n emit,\n )\n : hasCapability(hasExSearch)\n}\n\n/**\n * A numeric column's formatted value without cached strings (#27): what the default formatter\n * shows, `String(v)`, or `''` for a `null` / `undefined` cell (stored as `NaN`, like a real `NaN`,\n * which shows `'NaN'`).\n */\nfunction numberReader(values: Float64Array): Read {\n const nullish = nullishOf(values)\n return (r) => {\n const v = values[r] as number\n return !Number.isNaN(v) ? String(v) : nullish?.[r] ? '' : 'NaN'\n }\n}\n\n/**\n * One pass over a column's values (`get(r)` per row, time-sliced by `CHUNK` rows): their text as\n * the default formatter shows a primitive (`String(v)`, `''` when nullish), read while matching\n * (#27: no formatted copy per row). `false` at the first object or function (a `Date`, an array):\n * the column cache formats those. `undefined` when a read throws. The values are read in NFC (#30),\n * normalised here, never per keystroke: the rows whose value is not NFC (rare: NFD input from\n * macOS, Thai or Vietnamese keyboards) keep their NFC form in a sparse map. Only values with a code\n * unit from U+0300 (combining marks and up) can change.\n */\nfunction* scan(\n size: number,\n get: (r: number) => unknown,\n): Generator<undefined, Read | false | undefined, undefined> {\n const over = new Map<number, string>()\n try {\n for (let r = 0; r < size; r++) {\n const v = get(r)\n if (v !== null && (typeof v === 'object' || typeof v === 'function')) return false\n if (typeof v === 'string' && /[\\u0300-\\uffff]/.test(v)) {\n const n = v.normalize('NFC')\n if (n !== v) over.set(r, n)\n }\n if (r % CHUNK === CHUNK - 1) yield\n }\n } catch {\n return undefined\n }\n const read: Read = (r) => {\n const v = get(r)\n return v === null || v === undefined ? '' : String(v)\n }\n return over.size ? (r) => over.get(r) ?? read(r) : read\n}\n\n/**\n * Builds the matcher over the formatted values of the searchable columns (what the user sees), once\n * per data + columns, as a time-sliced job (§9.1): one column read per slice. Searchable columns are\n * those with a `search` weight > 0. When no column declares `search`, the columns of primitives\n * (strings, numbers, booleans, nullable) are searchable at weight 1, and a column of dates, arrays\n * or other objects is not (#27: it would keep a formatted string per row; it opts in with a\n * weight; `DK_SEARCH_COLUMN_SKIPPED` in development). `null` when nothing is searchable. Nothing\n * is copied per row (§9.3 memory): a key column of primitives without `format` is read from the\n * rows and stringified while matching (no values array, #27); an accessor column of primitives\n * reads the column cache's values the same way; only a column with `format` or an opted-in object\n * column (dates, arrays) keeps its formatted strings in the column cache. A key column whose read\n * throws goes through the column cache too, which reports it. The case is folded per chunk while\n * matching.\n */\nexport function* buildIndex(\n ctx: RowModelContext,\n config: MatchConfig,\n useExSearch: boolean,\n): Generator<undefined, SearchIndex | null, undefined> {\n const cols = resolveColumns(ctx.columns)\n const declared = cols.list.some((c) => (c.def as { search?: unknown }).search !== undefined)\n const data = ctx.data\n const n = data.length\n const keys: Key[] = []\n const reads: Read[] = []\n for (const col of cols.list) {\n if (cols.byId.get(col.id) !== col) continue\n const w = (col.def as { search?: unknown }).search\n const weight = declared ? (typeof w === 'number' && w > 0 ? w : 0) : 1\n if (weight === 0) continue\n const path = col.format ? undefined : col.path\n let failed = false\n const onError = () => {\n failed = true\n }\n let read = path ? yield* scan(n, (r) => getValue(data[r], path, onError)) : undefined\n if (failed || (read === false && declared)) read = undefined\n if (read === undefined) {\n const values = ctx.getColumnValues(col.id)\n if (!values) continue\n yield // reading a column is a task of its own (#40)\n const numbers = values instanceof Float64Array\n if (numbers && !col.format) read = numberReader(values)\n else {\n // Zero-config: a column of objects is not searched, `format` or not (#27).\n const own =\n numbers || (declared && col.format) ? undefined : yield* scan(n, (r) => values[r])\n if (own === false && !declared) read = false\n else if (own && !col.format) read = own\n else {\n // Time-sliced in a table run (#40): dates or a heavy `format` are not one long task.\n const sliced = (ctx as RowModelContext & SlicedFormatting).formattedSliced\n const formatted = sliced ? yield* sliced(col.id) : ctx.getFormattedValues(col.id)\n if (!formatted) continue\n read = yield* scan(n, (r) => formatted[r])\n }\n }\n }\n if (!read) {\n if ((process.env.NODE_ENV !== \"production\") && read === false) {\n ctx.emit('DK_SEARCH_COLUMN_SKIPPED', { column: col.id, index: col.index })\n }\n continue\n }\n keys.push({ name: `c${keys.length}`, weight })\n reads.push(read)\n yield\n }\n if (keys.length === 0) return null\n const w = keys.map((k) => k.weight).join()\n /**\n * A 53-bit hash of row `r`'s searched texts (#95), cyrb53: two 32-bit lanes over the code units\n * plus one (1–65536), each text closed by a 0 (`charCodeAt` past the end is `NaN`, read as 0 by\n * `^`), so two different lists of texts hash different sequences; the lanes are mixed at the end.\n * A changed row is compared with at most two stored hashes: the chance that it keeps a stale\n * score (a collision) is at most 2·2⁻⁵³ ≈ 2.2·10⁻¹⁶ per changed row (accepted for #95).\n */\n const hash = (r: number) => {\n let a = 0xdeadbeef\n let b = 0x41c6ce57\n for (const read of reads) {\n const v = read(r)\n for (let j = 0; j <= v.length; j++) {\n const c = v.charCodeAt(j) + 1\n a = Math.imul(a ^ c, 2654435761)\n b = Math.imul(b ^ c, 1597334677)\n }\n }\n a = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909)\n b = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(a ^ (a >>> 13), 3266489909)\n return 4294967296 * (2097151 & b) + (a >>> 0)\n }\n // Values (NFC already) are folded per chunk while matching; the query is normalised as well, so\n // NFC and NFD spellings match each other like in the `contains` filter (#30).\n const fold = (v: string) => (config.caseSensitive ? v : v.toLowerCase())\n const foldQuery = (q: string) => fold(q.normalize('NFC'))\n const at = (k: number, r: number) => fold((reads[k] as Read)(r))\n const matchChunk = (useExSearch ? exSearchIndex : containsIndex)(keys, at, config)\n /**\n * The hashes of this data version, computed on a row's first match (`-1`: not yet). The texts do\n * not depend on the query: a keystroke reuses them and hashes no row (#95).\n */\n const h = new Float64Array(n).fill(-1)\n return {\n *match(rows, query, memo) {\n const q = foldQuery(query)\n const prev = memo?.scores\n const old = prev?.q === query && prev.w === w ? prev : undefined\n const d = old ? old.h.length - n : 0\n // Same data (the scores hold this index's hashes), a narrowing query: a row the previous\n // match scored without a hit is not matched again; its hits and the rows it did not see\n // (`NaN`: filtered out then) are.\n const cut =\n !old && prev?.h === h && prev.w === w && matchChunk.narrows?.(foldQuery(prev.q), q)\n const s = new Float64Array(n).fill(Number.NaN)\n const size = Math.max(256, Math.floor(CELLS / keys.length))\n const hits: Hit[] = []\n for (let k = 0; k < rows.length; k += size) {\n if (k) yield\n const chunk = rows.subarray(k, k + size)\n const miss: number[] = []\n for (const r of chunk) {\n let x = h[r] as number\n if (x < 0) {\n x = hash(r)\n h[r] = x\n }\n s[r] = -1\n if (cut) {\n if ((prev.s[r] as number) !== -1) miss.push(r)\n continue\n }\n if (!old) continue\n // Out of range reads `undefined`: never equal, never `>= -1` (like `NaN`: not matched).\n const j = old.h[r] === x ? r : old.h[r + d] === x ? r + d : -1\n const v = old.s[j] as number\n if (v >= -1) {\n s[r] = v\n if (v >= 0) hits.push({ i: r, score: v })\n } else miss.push(r)\n }\n // A new query (no scores to keep) matches the chunk as it is.\n const todo = old || cut ? Uint32Array.from(miss) : chunk\n if (todo.length) {\n for (const hit of matchChunk(todo, q)) {\n s[hit.i] = hit.score\n hits.push(hit)\n }\n }\n }\n if (memo) memo.scores = { q: query, w, h, s }\n return rankHits(hits)\n },\n }\n}\n\n/**\n * The projection as ex-search reads it: an array (`length`, index reads) of `{ i, c0, … }` items,\n * each built when read and not kept. It shows the chunk of rows being matched; ex-search holds\n * this one view (`setData` once).\n */\nfunction projection(keys: readonly Key[], at: (k: number, r: number) => string) {\n let rows: Uint32Array = new Uint32Array(0)\n /** The items built for the chunk shown (ex-search 1.2.1 reads a row to split it, then to score it). */\n let held: Record<string, string | number>[] = []\n // Never written, and its own `length` stays 0: `length` is read through the trap below (setting\n // a large `length` would allocate a backing store).\n const target: Item[] = []\n const items = new Proxy(target, {\n get(t, p) {\n if (p === 'length') return rows.length\n const j = arrayIndex(p, rows.length)\n if (j < 0) return t[p as keyof Item[]]\n if (held[j]) return held[j]\n const r = rows[j] as number\n const o = { i: r } as Record<string, string | number>\n for (let k = 0; k < keys.length; k++) o[(keys[k] as Key).name] = at(k, r)\n held[j] = o\n return o\n },\n has: (t, p) => arrayIndex(p, rows.length) >= 0 || p in t,\n })\n return {\n items,\n /** Shows `chunk` (row indices), each item built once until the next `show`. */\n show(chunk: Uint32Array) {\n rows = chunk\n held = []\n },\n }\n}\n\n/** The hits of one chunk of rows for a folded query (`score` decides alone, so chunks match like the whole). */\ntype MatchChunk = ((chunk: Uint32Array, query: string) => Hit[]) & {\n /**\n * #96 (c): matching `next` within the hits of `prev` (both folded, as ex-search scores them)\n * provably equals matching every row. Missing (or `undefined`): never.\n */\n narrows?: (prev: string, next: string) => boolean | undefined\n}\n\nfunction exSearchIndex(\n keys: readonly Key[],\n at: (k: number, r: number) => string,\n config: MatchConfig,\n): MatchChunk {\n // Values and query are folded already (buildIndex).\n const engine = new ExSearch<Item>({\n keys: keys as Key[],\n ...config,\n caseSensitive: true,\n // DataKable re-ranks with exact scores and an index tie-break; tim keeps ex-search's own pass\n // stable and avoids radix's score quantisation.\n sortAlgorithm: 'tim',\n })\n // A one-word query is measured against the whole value and each of its words, the best wins: a\n // typo of one word of a multi-word cell matches (#10). ex-search's `fuzzyMode: 'word'` does it\n // with its bounded matcher (#106). A query with whitespace is measured against whole values only,\n // as before (a single word is never that query), and is decided once per query, not per row (#96).\n // ex-search 1.2.1 splits a value into words once per `setData` and keeps the word offsets (its\n // word index, 16 bytes per cell of two words). Kept for every row, it would take DataKable over\n // its 5 MB budget at 100k rows (§9.3, #96), so it is built for each chunk the view shows and\n // dropped after it (`updateConfig`): values are split per chunk, each row read once.\n // With `fuzzyMaxDistance: 0` no word can score (distance 0 is equality, which the exact and\n // contains tiers score already): values are not split at all, same results (#96).\n const view = projection(keys, at)\n engine.setData(view.items)\n const match = (chunk: Uint32Array, q: string) => {\n // Set per chunk, like the view: another job may have matched in between.\n engine.updateConfig({ fuzzyMode: !config.fuzzyMaxDistance || /\\s/.test(q) ? 'value' : 'word' })\n view.show(chunk)\n // Results are copies of the items (`Omit<Item, …>` drops `i` from the type, not the value).\n const hits = engine.search(q) as unknown as Hit[]\n engine.updateConfig({})\n // The same rows again: drops the items built for them.\n view.show(chunk)\n return hits\n }\n // ex-search's `canNarrow` (1.2.1; 1.2.0 never narrows): fuzzy matching off, `prev` inside `next`\n // and no weight that lets a row reach `threshold` on `next` but not on `prev`; or the same query.\n // Queries are compared as given: DataKable's own (trimmed, NFC, folded).\n match.narrows = (prev: string, next: string) => engine.canNarrow?.(prev, next)\n return match\n}\n\n/** The fallback when ex-search is missing or incompatible: exact 1 · prefix 0.8 · substring 0.65. */\nfunction containsIndex(\n keys: readonly Key[],\n at: (k: number, r: number) => string,\n config: MatchConfig,\n): MatchChunk {\n return (chunk, q) => {\n const hits: Hit[] = []\n for (let j = 0; j < chunk.length; j++) {\n const r = chunk[j] as number\n let best = 0\n for (let k = 0; k < keys.length; k++) {\n const v = at(k, r)\n const raw = v === q ? 1 : v.startsWith(q) ? 0.8 : v.includes(q) ? 0.65 : 0\n const w = (keys[k] as Key).weight\n if (raw * w > best) best = raw * w\n }\n if (best > 0 && best >= config.threshold) hits.push({ i: r, score: best })\n }\n return hits\n }\n}\n\n/**\n * Hits in relevance order (§6.3): score descending, ties by original row index. Enforced here so\n * the order never depends on the matcher's own ranking. Sorts `hits` (a fresh array) in place.\n */\nexport function rankHits(hits: Hit[]): Uint32Array {\n return Uint32Array.from(\n hits.sort((a, b) => b.score - a.score || a.i - b.i),\n (h) => h.i,\n )\n}\n"],"mappings":"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","names":["w"]}
@@ -1,5 +1,5 @@
1
1
  // src/features/search/search-index.ts
2
- import { ExSearch, Scorer } from "ex-search";
2
+ import { ExSearch } from "ex-search";
3
3
  import { getValue } from "../../column/get-value.js";
4
4
  import "../../diagnostics/process-guard.js";
5
5
  import { nullishOf } from "../../pipeline/column-cache.js";
@@ -8,7 +8,7 @@ import { arrayIndex } from "../../pipeline/row.js";
8
8
  import { detectCapability, hasCapability } from "../../versioning/capability.js";
9
9
  var CHUNK = 4096;
10
10
  var CELLS = 16384;
11
- var hasExSearch = () => [ExSearch.prototype.search, ExSearch.prototype.setData, Scorer.fuzzy].every(
11
+ var hasExSearch = () => [ExSearch.prototype.search, ExSearch.prototype.setData, ExSearch.prototype.updateConfig].every(
12
12
  (f) => typeof f === "function"
13
13
  );
14
14
  function probeExSearch(emit) {
@@ -17,8 +17,8 @@ function probeExSearch(emit) {
17
17
  {
18
18
  feature: "search",
19
19
  dependency: "ex-search",
20
- capability: "ExSearch, Scorer.fuzzy",
21
- supported: "^1.1.1",
20
+ capability: "ExSearch (fuzzyMode: 'word')",
21
+ supported: "^1.2.0",
22
22
  fallback: "case-insensitive exact / prefix / substring matching (no fuzzy matching)"
23
23
  },
24
24
  emit
@@ -61,8 +61,8 @@ function* buildIndex(ctx, config, useExSearch) {
61
61
  const reads = [];
62
62
  for (const col of cols.list) {
63
63
  if (cols.byId.get(col.id) !== col) continue;
64
- const w = col.def.search;
65
- const weight = declared ? typeof w === "number" && w > 0 ? w : 0 : 1;
64
+ const w2 = col.def.search;
65
+ const weight = declared ? typeof w2 === "number" && w2 > 0 ? w2 : 0 : 1;
66
66
  if (weight === 0) continue;
67
67
  const path = col.format ? void 0 : col.path;
68
68
  let failed = false;
@@ -100,48 +100,101 @@ function* buildIndex(ctx, config, useExSearch) {
100
100
  yield;
101
101
  }
102
102
  if (keys.length === 0) return null;
103
+ const w = keys.map((k) => k.weight).join();
104
+ const hash = (r) => {
105
+ let a = 3735928559;
106
+ let b = 1103547991;
107
+ for (const read of reads) {
108
+ const v = read(r);
109
+ for (let j = 0; j <= v.length; j++) {
110
+ const c = v.charCodeAt(j) + 1;
111
+ a = Math.imul(a ^ c, 2654435761);
112
+ b = Math.imul(b ^ c, 1597334677);
113
+ }
114
+ }
115
+ a = Math.imul(a ^ a >>> 16, 2246822507) ^ Math.imul(b ^ b >>> 13, 3266489909);
116
+ b = Math.imul(b ^ b >>> 16, 2246822507) ^ Math.imul(a ^ a >>> 13, 3266489909);
117
+ return 4294967296 * (2097151 & b) + (a >>> 0);
118
+ };
103
119
  const fold = (v) => config.caseSensitive ? v : v.toLowerCase();
104
120
  const foldQuery = (q) => fold(q.normalize("NFC"));
105
121
  const at = (k, r) => fold(reads[k](r));
106
- return useExSearch ? exSearchIndex(ctx.data.length, keys, at, foldQuery, config) : containsIndex(keys, at, foldQuery, config);
107
- }
108
- function* chunked(rows, columns, matchChunk) {
109
- const size = Math.max(256, Math.floor(CELLS / columns));
110
- if (rows.length <= size) return rankHits(matchChunk(rows));
111
- const hits = [];
112
- for (let k = 0; k < rows.length; k += size) {
113
- for (const h of matchChunk(rows.subarray(k, k + size))) hits.push(h);
114
- yield;
115
- }
116
- return rankHits(hits);
122
+ const matchChunk = (useExSearch ? exSearchIndex : containsIndex)(keys, at, config);
123
+ const h = new Float64Array(n).fill(-1);
124
+ return {
125
+ *match(rows, query, memo) {
126
+ const q = foldQuery(query);
127
+ const prev = memo?.scores;
128
+ const old = prev?.q === query && prev.w === w ? prev : void 0;
129
+ const d = old ? old.h.length - n : 0;
130
+ const cut = !old && prev?.h === h && prev.w === w && matchChunk.narrows?.(foldQuery(prev.q), q);
131
+ const s = new Float64Array(n).fill(Number.NaN);
132
+ const size = Math.max(256, Math.floor(CELLS / keys.length));
133
+ const hits = [];
134
+ for (let k = 0; k < rows.length; k += size) {
135
+ if (k) yield;
136
+ const chunk = rows.subarray(k, k + size);
137
+ const miss = [];
138
+ for (const r of chunk) {
139
+ let x = h[r];
140
+ if (x < 0) {
141
+ x = hash(r);
142
+ h[r] = x;
143
+ }
144
+ s[r] = -1;
145
+ if (cut) {
146
+ if (prev.s[r] !== -1) miss.push(r);
147
+ continue;
148
+ }
149
+ if (!old) continue;
150
+ const j = old.h[r] === x ? r : old.h[r + d] === x ? r + d : -1;
151
+ const v = old.s[j];
152
+ if (v >= -1) {
153
+ s[r] = v;
154
+ if (v >= 0) hits.push({ i: r, score: v });
155
+ } else miss.push(r);
156
+ }
157
+ const todo = old || cut ? Uint32Array.from(miss) : chunk;
158
+ if (todo.length) {
159
+ for (const hit of matchChunk(todo, q)) {
160
+ s[hit.i] = hit.score;
161
+ hits.push(hit);
162
+ }
163
+ }
164
+ }
165
+ if (memo) memo.scores = { q: query, w, h, s };
166
+ return rankHits(hits);
167
+ }
168
+ };
117
169
  }
118
- function projection(size, keys, at) {
119
- let rows;
170
+ function projection(keys, at) {
171
+ let rows = new Uint32Array(0);
172
+ let held = [];
120
173
  const target = [];
121
- const length = () => rows ? rows.length : size;
122
- const item = (j) => {
123
- const r = rows ? rows[j] : j;
124
- const o = { i: r };
125
- for (let k = 0; k < keys.length; k++) o[keys[k].name] = at(k, r);
126
- return o;
127
- };
128
174
  const items = new Proxy(target, {
129
175
  get(t, p) {
130
- if (p === "length") return length();
131
- const j = arrayIndex(p, length());
132
- return j >= 0 ? item(j) : t[p];
176
+ if (p === "length") return rows.length;
177
+ const j = arrayIndex(p, rows.length);
178
+ if (j < 0) return t[p];
179
+ if (held[j]) return held[j];
180
+ const r = rows[j];
181
+ const o = { i: r };
182
+ for (let k = 0; k < keys.length; k++) o[keys[k].name] = at(k, r);
183
+ held[j] = o;
184
+ return o;
133
185
  },
134
- has: (t, p) => arrayIndex(p, length()) >= 0 || p in t
186
+ has: (t, p) => arrayIndex(p, rows.length) >= 0 || p in t
135
187
  });
136
188
  return {
137
189
  items,
138
- /** Shows `chunk` (row indices), or every row again (`undefined`). */
190
+ /** Shows `chunk` (row indices), each item built once until the next `show`. */
139
191
  show(chunk) {
140
192
  rows = chunk;
193
+ held = [];
141
194
  }
142
195
  };
143
196
  }
144
- function exSearchIndex(size, keys, at, fold, config) {
197
+ function exSearchIndex(keys, at, config) {
145
198
  const engine = new ExSearch({
146
199
  keys,
147
200
  ...config,
@@ -150,60 +203,34 @@ function exSearchIndex(size, keys, at, fold, config) {
150
203
  // stable and avoids radix's score quantisation.
151
204
  sortAlgorithm: "tim"
152
205
  });
153
- const max = config.fuzzyMaxDistance;
154
- let q = "";
155
- const near = (k, r) => {
156
- const v = at(k, r);
157
- if (v.includes(q) || /\s/.test(q)) return v;
158
- let best = "";
159
- let top = 0;
160
- const words = v.split(/\s+/);
161
- for (const w of words[1] ? [v, ...words] : words) {
162
- const s = Math.abs(w.length - q.length) > max ? 0 : Scorer.fuzzy(w, q, max, true);
163
- if (s > top) {
164
- best = w;
165
- top = s;
166
- }
167
- }
168
- return best;
169
- };
170
- const view = projection(size, keys, near);
206
+ const view = projection(keys, at);
171
207
  engine.setData(view.items);
172
- return {
173
- match(rows, query) {
174
- const folded = fold(query);
175
- return chunked(rows, keys.length, (chunk) => {
176
- q = folded;
177
- view.show(chunk);
178
- try {
179
- return engine.search(q);
180
- } finally {
181
- view.show(void 0);
182
- }
183
- });
184
- }
208
+ const match = (chunk, q) => {
209
+ engine.updateConfig({ fuzzyMode: !config.fuzzyMaxDistance || /\s/.test(q) ? "value" : "word" });
210
+ view.show(chunk);
211
+ const hits = engine.search(q);
212
+ engine.updateConfig({});
213
+ view.show(chunk);
214
+ return hits;
185
215
  };
216
+ match.narrows = (prev, next) => engine.canNarrow?.(prev, next);
217
+ return match;
186
218
  }
187
- function containsIndex(keys, at, fold, config) {
188
- return {
189
- match(rows, query) {
190
- const q = fold(query);
191
- return chunked(rows, keys.length, (chunk) => {
192
- const hits = [];
193
- for (let j = 0; j < chunk.length; j++) {
194
- const r = chunk[j];
195
- let best = 0;
196
- for (let k = 0; k < keys.length; k++) {
197
- const v = at(k, r);
198
- const raw = v === q ? 1 : v.startsWith(q) ? 0.8 : v.includes(q) ? 0.65 : 0;
199
- const w = keys[k].weight;
200
- if (raw * w > best) best = raw * w;
201
- }
202
- if (best > 0 && best >= config.threshold) hits.push({ i: r, score: best });
203
- }
204
- return hits;
205
- });
219
+ function containsIndex(keys, at, config) {
220
+ return (chunk, q) => {
221
+ const hits = [];
222
+ for (let j = 0; j < chunk.length; j++) {
223
+ const r = chunk[j];
224
+ let best = 0;
225
+ for (let k = 0; k < keys.length; k++) {
226
+ const v = at(k, r);
227
+ const raw = v === q ? 1 : v.startsWith(q) ? 0.8 : v.includes(q) ? 0.65 : 0;
228
+ const w = keys[k].weight;
229
+ if (raw * w > best) best = raw * w;
230
+ }
231
+ if (best > 0 && best >= config.threshold) hits.push({ i: r, score: best });
206
232
  }
233
+ return hits;
207
234
  };
208
235
  }
209
236
  function rankHits(hits) {