@dogsbay/search 0.2.0-beta.133 → 0.2.0-beta.134

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/README.md CHANGED
@@ -54,6 +54,8 @@ if (page) index.add(page.doc, page.parts);
54
54
  for (const [file, content] of index.emitFiles()) write(`_search/${file}`, content);
55
55
  ```
56
56
 
57
+ On a large site, call `index.takeReadyFiles()` after each `add` and write what it yields: each fragment file is handed over as soon as it is complete, so the builder does not hold every page's excerpt to the end. `emitFiles()` then writes the rest. The builder keeps its postings in typed arrays (about 4 bytes a number): dogsbay-ssg measured OpenShift's 1,799 pages building with search on in a 128 MB Worker heap, which the per-term arrays did not allow. Add every page before calling `emitFiles()`: `add()` after it throws.
58
+
57
59
  - `@dogsbay/search/index`: `SearchIndexBuilder`, and `extractText()` for a dogsbay document tree. It reads API reference nodes too: an OpenAPI operation's method and path, summary, description, parameters and schema properties (nested ones included); autodoc symbol names (as headings) and docstrings; and an embedded spec's descriptions. Code (signatures, source, samples) is skipped, as code blocks are.
58
60
  - `@dogsbay/search/html`: `extractHtml()`.
59
61
  - `@dogsbay/search/query`: `search()`, `parseQuery()`, `scriptData()`, `memoryData()`.
package/dist/index.d.ts CHANGED
@@ -54,17 +54,41 @@ declare class SearchIndexBuilder {
54
54
  /** Token counts per document and field (title, headings, body), for length normalisation. */
55
55
  private lengths;
56
56
  /**
57
- * term → flat [doc, counts, doc, counts, …]. Documents arrive in order, so
58
- * each page's counts are summed locally and appended once. Flat arrays
59
- * cost a fraction of a Map per term, which matters inside a Worker.
57
+ * The postings as one append-only log of (term id, counts) pairs in Int32Array chunks, in
58
+ * document order, and where each document's pairs start. An array per term cost about 16
59
+ * bytes a number with its header, Map entry and growth slack: on OpenShift (1,799 pages,
60
+ * 49,014 terms) 20.7 MB of a 128 MB Worker, which search-on could not build in. The log is
61
+ * 4 bytes a number; `emitFiles` groups it by term once (request from dogsbay-ssg, 2026-10-22).
60
62
  */
61
- private postings;
63
+ private chunks;
64
+ /** Numbers written to the log. */
65
+ private used;
66
+ /** Where each document's pairs start in the log. */
67
+ private docStart;
68
+ /** term → id, in first-seen order (shard grouping and key order follow it). */
69
+ private termIds;
70
+ /** Postings per term id. */
71
+ private counts;
72
+ /** Fragment blocks already handed over by `takeReadyFiles`. */
73
+ private fragmentsOut;
74
+ /** Set once `emitFiles` starts: the log is grouped then, and pages can no longer be added. */
75
+ private emitted;
76
+ /** The log grouped by term ([doc, counts, …] per term id from `offset[id]`), kept until every shard is written. */
77
+ private grouped?;
62
78
  add(doc: SearchDoc, parts: {
63
79
  title: string;
64
80
  headings: string[];
65
81
  body: string;
66
82
  keywords?: string[];
67
83
  }): void;
84
+ /**
85
+ * The fragment files complete so far (every BLOCK documents make one), each handed over once
86
+ * and then let go: a caller that drains this after each `add` does not hold every page's
87
+ * fragment to the end of the build (2.5M characters on OpenShift). `emitFiles` writes the rest.
88
+ */
89
+ takeReadyFiles(): Generator<[string, string]>;
90
+ /** Fragment block `b`. */
91
+ private fragmentFile;
68
92
  /** The stored score of one posting: saturated, length-normalised field scores, weighted and summed. */
69
93
  private scorer;
70
94
  get size(): number;
@@ -82,6 +106,9 @@ declare class SearchIndexBuilder {
82
106
  * one shard per term prefix. Each shard is built, serialised and handed
83
107
  * over before the next, and its postings are dropped, so writing the index
84
108
  * needs memory for one shard rather than for every shard and its JSON.
109
+ * The postings log is grouped by term first, each chunk let go once read.
110
+ * A call stopped part way leaves the shards not yet written to the next call;
111
+ * no page can be added once it has started.
85
112
  */
86
113
  emitFiles(): Generator<[string, string]>;
87
114
  }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const MAX_DEPTH = 3;
18
18
  function keyDepth(k) {
19
19
  return k.replace(/_[0-9a-f]+_/g, "x").length;
20
20
  }
21
+ const CHUNK = 1 << 18;
21
22
  class SearchIndexBuilder {
22
23
  constructor(opts = {}) {
23
24
  this.opts = opts;
@@ -27,12 +28,29 @@ class SearchIndexBuilder {
27
28
  /** Token counts per document and field (title, headings, body), for length normalisation. */
28
29
  lengths = [];
29
30
  /**
30
- * term → flat [doc, counts, doc, counts, …]. Documents arrive in order, so
31
- * each page's counts are summed locally and appended once. Flat arrays
32
- * cost a fraction of a Map per term, which matters inside a Worker.
31
+ * The postings as one append-only log of (term id, counts) pairs in Int32Array chunks, in
32
+ * document order, and where each document's pairs start. An array per term cost about 16
33
+ * bytes a number with its header, Map entry and growth slack: on OpenShift (1,799 pages,
34
+ * 49,014 terms) 20.7 MB of a 128 MB Worker, which search-on could not build in. The log is
35
+ * 4 bytes a number; `emitFiles` groups it by term once (request from dogsbay-ssg, 2026-10-22).
33
36
  */
34
- postings = /* @__PURE__ */ new Map();
37
+ chunks = [];
38
+ /** Numbers written to the log. */
39
+ used = 0;
40
+ /** Where each document's pairs start in the log. */
41
+ docStart = [];
42
+ /** term → id, in first-seen order (shard grouping and key order follow it). */
43
+ termIds = /* @__PURE__ */ new Map();
44
+ /** Postings per term id. */
45
+ counts = new Int32Array(1024);
46
+ /** Fragment blocks already handed over by `takeReadyFiles`. */
47
+ fragmentsOut = 0;
48
+ /** Set once `emitFiles` starts: the log is grouped then, and pages can no longer be added. */
49
+ emitted = false;
50
+ /** The log grouped by term ([doc, counts, …] per term id from `offset[id]`), kept until every shard is written. */
51
+ grouped;
35
52
  add(doc, parts) {
53
+ if (this.emitted) throw new Error("SearchIndexBuilder: add() after emitFiles(): the index has been written");
36
54
  const id = this.docs.length;
37
55
  this.docs.push({
38
56
  title: detach(doc.title),
@@ -57,12 +75,45 @@ class SearchIndexBuilder {
57
75
  for (const h of parts.headings) count(h, B_HEAD, 15, 1);
58
76
  count(parts.body, 1, 4095, 2);
59
77
  this.lengths.push(len[0], len[1], len[2]);
78
+ this.docStart.push(this.used);
60
79
  for (const [t, v] of counts) {
61
- const list = this.postings.get(t);
62
- if (list) list.push(id, v);
63
- else this.postings.set(detach(t), [id, v]);
80
+ let term = this.termIds.get(t);
81
+ if (term === void 0) {
82
+ term = this.termIds.size;
83
+ this.termIds.set(detach(t), term);
84
+ if (term === this.counts.length) {
85
+ const grown = new Int32Array(this.counts.length * 2);
86
+ grown.set(this.counts);
87
+ this.counts = grown;
88
+ }
89
+ }
90
+ this.counts[term]++;
91
+ const at = this.used % CHUNK;
92
+ if (at === 0) this.chunks.push(new Int32Array(CHUNK));
93
+ const chunk = this.chunks[this.chunks.length - 1];
94
+ chunk[at] = term;
95
+ chunk[at + 1] = v;
96
+ this.used += 2;
64
97
  }
65
98
  }
99
+ /**
100
+ * The fragment files complete so far (every BLOCK documents make one), each handed over once
101
+ * and then let go: a caller that drains this after each `add` does not hold every page's
102
+ * fragment to the end of the build (2.5M characters on OpenShift). `emitFiles` writes the rest.
103
+ */
104
+ *takeReadyFiles() {
105
+ if (this.emitted) return;
106
+ while ((this.fragmentsOut + 1) * BLOCK <= this.docs.length) {
107
+ const b = this.fragmentsOut++;
108
+ yield this.fragmentFile(b);
109
+ for (let i = b * BLOCK; i < (b + 1) * BLOCK; i++) this.docs[i].fragment = "";
110
+ }
111
+ }
112
+ /** Fragment block `b`. */
113
+ fragmentFile(b) {
114
+ const part = this.docs.slice(b * BLOCK, (b + 1) * BLOCK);
115
+ return [file.fragment(b), wrapJson(key.fragment(b), `[${part.map((d) => d.fragment).join(",")}]`)];
116
+ }
66
117
  /** The stored score of one posting: saturated, length-normalised field scores, weighted and summed. */
67
118
  scorer() {
68
119
  const n = this.docs.length || 1;
@@ -96,7 +147,7 @@ class SearchIndexBuilder {
96
147
  */
97
148
  shardTerms() {
98
149
  const out = /* @__PURE__ */ new Map();
99
- const size = (terms) => terms.reduce((n, t) => n + t.length + 4 + this.postings.get(t).length * 4, 0);
150
+ const size = (terms) => terms.reduce((n, t) => n + t.length + 4 + this.counts[this.termIds.get(t)] * 2 * 4, 0);
100
151
  const place = (terms, depth) => {
101
152
  const groups = /* @__PURE__ */ new Map();
102
153
  for (const t of terms) {
@@ -113,7 +164,7 @@ class SearchIndexBuilder {
113
164
  } else out.set(k, g);
114
165
  }
115
166
  };
116
- place([...this.postings.keys()], 1);
167
+ place([...this.termIds.keys()], 1);
117
168
  return out;
118
169
  }
119
170
  /** Emit the index files, keyed by path relative to the unit's `_dogsbay/search/` dir. */
@@ -125,8 +176,12 @@ class SearchIndexBuilder {
125
176
  * one shard per term prefix. Each shard is built, serialised and handed
126
177
  * over before the next, and its postings are dropped, so writing the index
127
178
  * needs memory for one shard rather than for every shard and its JSON.
179
+ * The postings log is grouped by term first, each chunk let go once read.
180
+ * A call stopped part way leaves the shards not yet written to the next call;
181
+ * no page can be added once it has started.
128
182
  */
129
183
  *emitFiles() {
184
+ this.emitted = true;
130
185
  const byShard = this.shardTerms();
131
186
  const depth = Math.max(1, ...[...byShard.keys()].map((k) => keyDepth(k)));
132
187
  const score = this.scorer();
@@ -147,24 +202,46 @@ class SearchIndexBuilder {
147
202
  });
148
203
  const meta = { format: FORMAT, depth, count: this.docs.length, shards: [...byShard.keys()].sort(), sections, tags, block: BLOCK, lang: this.opts.lang, docs };
149
204
  yield [file.docs, wrap(key.docs, meta)];
150
- for (let b = 0; b * BLOCK < this.docs.length; b++) {
151
- const part = this.docs.slice(b * BLOCK, (b + 1) * BLOCK);
152
- yield [file.fragment(b), wrapJson(key.fragment(b), `[${part.map((d) => d.fragment).join(",")}]`)];
205
+ for (let b = this.fragmentsOut; b * BLOCK < this.docs.length; b++) yield this.fragmentFile(b);
206
+ if (!this.grouped) {
207
+ const n = this.termIds.size;
208
+ const offset2 = new Int32Array(n + 1);
209
+ for (let i = 0; i < n; i++) offset2[i + 1] = offset2[i] + this.counts[i] * 2;
210
+ const flat2 = new Int32Array(this.used);
211
+ const fill = offset2.slice(0, n);
212
+ let doc = -1;
213
+ for (let p = 0; p < this.used; p += 2) {
214
+ while (doc + 1 < this.docStart.length && this.docStart[doc + 1] <= p) doc++;
215
+ const c = p / CHUNK | 0;
216
+ const at = p % CHUNK;
217
+ const chunk = this.chunks[c];
218
+ const term = chunk[at];
219
+ flat2[fill[term]] = doc;
220
+ flat2[fill[term] + 1] = chunk[at + 1];
221
+ fill[term] += 2;
222
+ if (at + 2 === CHUNK) this.chunks[c] = void 0;
223
+ }
224
+ this.chunks = [];
225
+ this.docStart = [];
226
+ this.used = 0;
227
+ this.grouped = { flat: flat2, offset: offset2 };
153
228
  }
229
+ const { flat, offset } = this.grouped;
154
230
  for (const [k, terms] of byShard) {
155
231
  const data = {};
156
232
  for (const term of terms) {
157
- const flat = this.postings.get(term);
233
+ const id = this.termIds.get(term);
158
234
  const pairs = [];
159
- for (let i = 0; i < flat.length; i += 2) pairs.push([flat[i], score(flat[i], flat[i + 1])]);
235
+ for (let i = offset[id]; i < offset[id + 1]; i += 2) pairs.push([flat[i], score(flat[i], flat[i + 1])]);
160
236
  pairs.sort((a, b) => b[1] - a[1]);
161
237
  const out = [];
162
238
  for (const [d, sc] of pairs) out.push(d, sc);
163
239
  data[term] = out;
164
- this.postings.delete(term);
240
+ this.termIds.delete(term);
165
241
  }
166
242
  yield [file.shard(k), wrap(key.shard(k), data)];
167
243
  }
244
+ this.grouped = void 0;
168
245
  }
169
246
  }
170
247
  const str = (v) => typeof v === "string" ? v : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/search",
3
- "version": "0.2.0-beta.133",
3
+ "version": "0.2.0-beta.134",
4
4
  "description": "Static search for docs sites: builds in a Worker, queries from file://",
5
5
  "type": "module",
6
6
  "main": "./dist/entry.js",
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "dependencies": {
44
44
  "stemmer": "^2.0.1",
45
- "@dogsbay/types": "0.2.0-beta.133"
45
+ "@dogsbay/types": "0.2.0-beta.134"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@playwright/test": "1.61.1",
@@ -52,7 +52,7 @@
52
52
  "tsup": "^8.5.1",
53
53
  "typescript": "^5.9.3",
54
54
  "vitest": "^4.1.10",
55
- "@dogsbay/format-openapi": "0.2.0-beta.133"
55
+ "@dogsbay/format-openapi": "0.2.0-beta.134"
56
56
  },
57
57
  "license": "MIT",
58
58
  "repository": {