@bongos/core 1.19.268 → 1.19.269

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.268",
6
- "core_contract": "1.19.268",
7
- "source_commit": "8404da50e0a2a9b46c5e644ea1c8df208dd39dcd",
5
+ "core_version": "1.19.269",
6
+ "core_contract": "1.19.269",
7
+ "source_commit": "eb9189a55c35d65f7b127a4f825cf15111450f45",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-20T19:10:01.449Z",
9
+ "built_at": "2026-08-20T23:06:25.339Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 318,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1367,
14
+ "functional_verbatim": 1368,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1707,
20
- "tree_sha256": "5f4702b206311a36e72366847e94d28a71690f05d8f0b0151c94d7a508b6a9e2",
19
+ "file_count": 1708,
20
+ "tree_sha256": "1bb1e8f0972473d957a4d18ab8cb07dc0e7320b9c00e1e504bbd939804fc8b3e",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3912,7 +3912,7 @@
3912
3912
  {
3913
3913
  "path": "modules/memory/search-chunker.js",
3914
3914
  "mode": "0000644",
3915
- "sha256": "5eaebeb1d9a8c134a7e5f7ab532486dcf608181ee212ab46bb5f9bcee9951637"
3915
+ "sha256": "b25ec0c42b9ea0ac856837e9a4282fd9ea4cb79a1c9258ee18bdf81e757c001e"
3916
3916
  },
3917
3917
  {
3918
3918
  "path": "modules/memory/search.js",
@@ -4472,12 +4472,12 @@
4472
4472
  {
4473
4473
  "path": "package-lock.json",
4474
4474
  "mode": "0000644",
4475
- "sha256": "219416dda396230765f92ee473d65aa00d9e8c28308e7a4cce723455da79927b"
4475
+ "sha256": "ddb6a432b5c2aab632e59179fa2429ac1055aa0eaac7ea30e94c7cba0e4cf8e8"
4476
4476
  },
4477
4477
  {
4478
4478
  "path": "package.json",
4479
4479
  "mode": "0000644",
4480
- "sha256": "22c9c3c9513d9e53575718a26daba800488dd9ab3f5b7521c83edf515b1f2b89"
4480
+ "sha256": "00d26fc1394b365885ef97c011108dfbe1734aa49ed388a8c49a308480fe3b5a"
4481
4481
  },
4482
4482
  {
4483
4483
  "path": "public-docs/index.html",
@@ -5497,12 +5497,12 @@
5497
5497
  {
5498
5498
  "path": "scripts/gds/search-ingest-db.js",
5499
5499
  "mode": "0000644",
5500
- "sha256": "c410dfff1628a48ab68b687306335cf2fe73513c04888e73cbb80ba4beb3951b"
5500
+ "sha256": "0723d48df92d78696b86adf0591584800f77da590afc80bcc5e3f99dcf614979"
5501
5501
  },
5502
5502
  {
5503
5503
  "path": "scripts/gds/search-ingest-md.js",
5504
5504
  "mode": "0000644",
5505
- "sha256": "19b4e880f0a44dcba9458d863ba10686fe67b90f3aed5b595f7b7db4e205e68a"
5505
+ "sha256": "802d16a48f6a47bf41b1768fff3da0ae1b18a4d73147b3559a781f18ea47c75c"
5506
5506
  },
5507
5507
  {
5508
5508
  "path": "scripts/gds/security-baseline-check.sh",
@@ -6122,7 +6122,7 @@
6122
6122
  {
6123
6123
  "path": "src/module-api.js",
6124
6124
  "mode": "0000644",
6125
- "sha256": "469fc55669b154bc1579b3b451f1dbd6f85851cdca493edbae8c943c0e67c1aa"
6125
+ "sha256": "f87f3562ac47d91ae9031fbc80d64b2200e1a7ca2c5bfebab13d0bf0f2197b2a"
6126
6126
  },
6127
6127
  {
6128
6128
  "path": "src/module-loader/catalog.js",
@@ -8099,6 +8099,11 @@
8099
8099
  "mode": "0000644",
8100
8100
  "sha256": "c80af8bfbb83f0ebdc44c196b8d127caf228a5e102382f254f4e55e1c1d96eaf"
8101
8101
  },
8102
+ {
8103
+ "path": "tests/search_ingest_nul.mjs",
8104
+ "mode": "0000644",
8105
+ "sha256": "a8687493e50f83430b337df76c77e2ecb9924df5012180ba1c853141e0646f88"
8106
+ },
8102
8107
  {
8103
8108
  "path": "tests/search_isolation.mjs",
8104
8109
  "mode": "0000644",
@@ -168,11 +168,37 @@ function splitLongBody(body, maxTokens) {
168
168
  // a typed ref like 'task:431' for DB prose); it's only used for the breadcrumb
169
169
  // root + returned for the caller's source_ref. Returns chunks in document order
170
170
  // with 0-based chunk_index.
171
+ // task 1002859: strip characters Postgres rejects in a text column. U+0000 is the
172
+ // one that matters: PG raises 'invalid byte sequence for encoding "UTF8": 0x00'
173
+ // on it, and BOTH ingesters (search-ingest-md.js for the tree, search-ingest-db.js
174
+ // for DB prose) hand chunk bodies straight to an INSERT. A NUL therefore aborted
175
+ // the whole ingest and /recall silently stopped re-indexing (task 1003097).
176
+ //
177
+ // Sanitizing HERE rather than in either ingester is deliberate: this is the one
178
+ // funnel both go through, so the guard cannot be added to one path and forgotten
179
+ // on the other. That is exactly what happened the first time this bug class was
180
+ // fixed — commit 75742cb (task 1002507) stripped NUL from ship.js snippetOf after
181
+ // a UTF-16LE `wsl --list` read voided a session digest, and left every other path
182
+ // carrying the same hazard.
183
+ //
184
+ // NUL is meaningless in prose, so dropping it loses nothing a reader wants. Lone
185
+ // surrogates go too: they are unpaired UTF-16 halves that cannot be encoded as
186
+ // valid UTF-8 — the same class of failure from the same class of cause (a file
187
+ // read in the wrong encoding).
188
+ const PG_INVALID_TEXT_RE = /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
189
+
190
+ function sanitizeForText(text) {
191
+ return typeof text === 'string' ? text.replace(PG_INVALID_TEXT_RE, '') : text;
192
+ }
193
+
171
194
  function chunkMarkdown({ path, content, maxTokens = DEFAULT_MAX_TOKENS, targetTokens = DEFAULT_TARGET_TOKENS } = {}) {
172
195
  // ADR 0095 C4: strip <private>-fenced content BEFORE chunking so it never
173
196
  // enters the recall index. Done first so front-matter parsing + heading
174
197
  // splitting see only the public text.
175
- const { frontmatter, body } = parseFrontMatter(stripPrivate(content));
198
+ // Sanitize FIRST, before any parsing: a NUL inside a heading or a front-matter
199
+ // value would otherwise ride out on `title`/`heading_path`, which are also
200
+ // Postgres text columns.
201
+ const { frontmatter, body } = parseFrontMatter(stripPrivate(sanitizeForText(content)));
176
202
  const root = fileLabel(path);
177
203
 
178
204
  // Title: explicit front-matter title/name → first H1 → file label.
@@ -249,4 +275,4 @@ function chunkMarkdown({ path, content, maxTokens = DEFAULT_MAX_TOKENS, targetTo
249
275
  return { path: path || null, title, frontmatter: frontmatter || null, chunks };
250
276
  }
251
277
 
252
- module.exports = { chunkMarkdown, parseFrontMatter, estTokens, fileLabel, stripPrivate, DEFAULT_TARGET_TOKENS, DEFAULT_MAX_TOKENS };
278
+ module.exports = { chunkMarkdown, parseFrontMatter, estTokens, fileLabel, stripPrivate, sanitizeForText, PG_INVALID_TEXT_RE, DEFAULT_TARGET_TOKENS, DEFAULT_MAX_TOKENS };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.268",
3
+ "version": "1.19.269",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.268",
9
+ "version": "1.19.269",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.268",
3
+ "version": "1.19.269",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -143,11 +143,26 @@ async function ingestRows(client, rows, sourceRef) {
143
143
  return stats;
144
144
  }
145
145
 
146
+ // task 1002859 (panel finding): bound the driver's error text before it reaches a
147
+ // log. A Postgres error message is unbounded upstream text and CAN embed row
148
+ // content (a constraint violation quotes the offending key), and these logs are
149
+ // deploy/cron sinks that get shipped around. One line, capped, is enough to
150
+ // identify the fault; the source_ref beside it is what actually locates it. The
151
+ // DB corpus makes this matter more than it looks — its prose is session-log and
152
+ // memory-derived text the file elsewhere treats as scrub-sensitive.
153
+ const ERR_LOG_MAX = 300;
154
+ function errLine(e) {
155
+ const raw = e && e.message ? String(e.message) : String(e);
156
+ const flat = raw.replace(/\s+/g, ' ').trim();
157
+ return flat.length > ERR_LOG_MAX ? `${flat.slice(0, ERR_LOG_MAX)}… (truncated)` : flat;
158
+ }
159
+
146
160
  async function main() {
147
161
  const dryRun = process.argv.includes('--dry-run');
148
162
  const pool = requirePool();
149
163
  const client = await pool.connect();
150
164
  const totals = { inserted: 0, updated: 0, unchanged: 0, pruned: 0, deleted: 0, sources: 0 };
165
+ const failures = []; // task 1002859 — per-source casualties, named at the end
151
166
  const presentRefs = [];
152
167
  try {
153
168
  // Gather every source's rows first (read-only), grouped by source_ref.
@@ -177,19 +192,39 @@ async function main() {
177
192
  return;
178
193
  }
179
194
 
180
- await client.query('BEGIN');
195
+ // task 1002859: ONE TRANSACTION PER SOURCE, not one for the whole run — the
196
+ // same all-or-nothing shape that made a single bad byte a total recall
197
+ // outage on the markdown side. DB prose is the MORE exposed of the two: it
198
+ // carries session-log and transcript-derived text, which is exactly where a
199
+ // wrong-encoding read introduces a NUL in the first place.
181
200
  for (const [ref, rows] of byRef) {
182
- const s = await ingestRows(client, rows, ref);
183
- totals.inserted += s.inserted; totals.updated += s.updated;
184
- totals.unchanged += s.unchanged; totals.pruned += s.pruned;
201
+ try {
202
+ await client.query('BEGIN');
203
+ const s = await ingestRows(client, rows, ref);
204
+ await client.query('COMMIT');
205
+ totals.inserted += s.inserted; totals.updated += s.updated;
206
+ totals.unchanged += s.unchanged; totals.pruned += s.pruned;
207
+ } catch (e) {
208
+ await client.query('ROLLBACK').catch(() => {});
209
+ failures.push({ sourceRef: ref, message: errLine(e) });
210
+ console.error(`search-ingest-db: SKIPPED ${ref} — ${errLine(e)}`);
211
+ }
185
212
  }
186
213
  // Delete db chunks whose source no longer exists (a deleted task/idea/etc.).
187
- const del = await client.query(
188
- `DELETE FROM search_chunks WHERE source_kind='db' AND NOT (source_ref = ANY($1::text[]))`,
189
- [presentRefs]
190
- );
191
- totals.deleted = del.rowCount || 0;
192
- await client.query('COMMIT');
214
+ // Own transaction, and SKIPPED after any failure: presentRefs is what we
215
+ // meant to write, so pruning against it on a partial run could delete a
216
+ // still-live source's chunks.
217
+ if (failures.length === 0) {
218
+ await client.query('BEGIN');
219
+ const del = await client.query(
220
+ `DELETE FROM search_chunks WHERE source_kind='db' AND NOT (source_ref = ANY($1::text[]))`,
221
+ [presentRefs]
222
+ );
223
+ totals.deleted = del.rowCount || 0;
224
+ await client.query('COMMIT');
225
+ } else {
226
+ console.error(`search-ingest-db: vanished-source prune SKIPPED — ${failures.length} source(s) failed, so presentRefs is incomplete`);
227
+ }
193
228
  } catch (e) {
194
229
  await client.query('ROLLBACK').catch(() => {});
195
230
  console.error('search-ingest-db: FAILED, rolled back:', e.message);
@@ -200,6 +235,13 @@ async function main() {
200
235
  client.release();
201
236
  await pool.end().catch(() => {});
202
237
  console.log(`search-ingest-db: inserted=${totals.inserted} updated=${totals.updated} unchanged=${totals.unchanged} pruned=${totals.pruned} deleted=${totals.deleted} across ${totals.sources} sources`);
238
+ if (failures.length > 0) {
239
+ // NON-SILENT: this runs best-effort from a scheduled job, so a non-zero exit
240
+ // is the only signal that reaches a log a human reads. Name every casualty.
241
+ console.error(`search-ingest-db: ${failures.length} source(s) FAILED and were skipped:`);
242
+ for (const f of failures) console.error(` ${f.sourceRef}: ${f.message}`);
243
+ process.exitCode = 1;
244
+ }
203
245
  }
204
246
 
205
247
  if (require.main === module) {
@@ -163,6 +163,20 @@ async function ingestFile(client, rows, sourceRef) {
163
163
  return stats;
164
164
  }
165
165
 
166
+ // task 1002859 (panel finding): bound the driver's error text before it reaches a
167
+ // log. A Postgres error message is unbounded upstream text and CAN embed row
168
+ // content (a constraint violation quotes the offending key), and these logs are
169
+ // deploy/cron sinks that get shipped around. One line, capped, is enough to
170
+ // identify the fault; the source_ref beside it is what actually locates it. The
171
+ // DB corpus makes this matter more than it looks — its prose is session-log and
172
+ // memory-derived text the file elsewhere treats as scrub-sensitive.
173
+ const ERR_LOG_MAX = 300;
174
+ function errLine(e) {
175
+ const raw = e && e.message ? String(e.message) : String(e);
176
+ const flat = raw.replace(/\s+/g, ' ').trim();
177
+ return flat.length > ERR_LOG_MAX ? `${flat.slice(0, ERR_LOG_MAX)}… (truncated)` : flat;
178
+ }
179
+
166
180
  async function main() {
167
181
  const args = process.argv.slice(2);
168
182
  const dryRun = args.includes('--dry-run');
@@ -191,32 +205,61 @@ async function main() {
191
205
  const pool = requirePool();
192
206
  const client = await pool.connect();
193
207
  const totals = { inserted: 0, updated: 0, unchanged: 0, pruned: 0, deleted: 0 };
194
- try {
195
- await client.query('BEGIN');
196
- for (const f of allRows) {
208
+ // task 1002859: ONE TRANSACTION PER FILE, not one for the whole run. The old
209
+ // shape opened a single BEGIN before this loop, so ANY failure on ANY file
210
+ // rolled back the ENTIRE index update — that is the "rolls back the entire
211
+ // re-index" in this task's title, and it is what turned a one-character data
212
+ // problem into a total, permanent recall outage. Per-file scope makes the
213
+ // blast radius one file: the other ~900 still land, and the failure is
214
+ // reported by name instead of vanishing.
215
+ const failures = [];
216
+ for (const f of allRows) {
217
+ try {
218
+ await client.query('BEGIN');
197
219
  const s = await ingestFile(client, f.rows, f.sourceRef);
220
+ await client.query('COMMIT');
198
221
  totals.inserted += s.inserted; totals.updated += s.updated;
199
222
  totals.unchanged += s.unchanged; totals.pruned += s.pruned;
223
+ } catch (e) {
224
+ await client.query('ROLLBACK').catch(() => {});
225
+ failures.push({ sourceRef: f.sourceRef, message: errLine(e) });
226
+ console.error(`search-ingest-md: SKIPPED ${f.sourceRef} — ${errLine(e)}`);
227
+ }
228
+ }
229
+ // Delete chunks for doc sources that no longer exist in the tree. Its own
230
+ // transaction, and deliberately SKIPPED when any file failed: `present` is the
231
+ // list of sources we intended to write, so pruning against it after a partial
232
+ // run could delete chunks for a file that only failed transiently.
233
+ try {
234
+ if (failures.length === 0) {
235
+ await client.query('BEGIN');
236
+ const present = allRows.map((f) => f.sourceRef);
237
+ const del = await client.query(
238
+ `DELETE FROM search_chunks WHERE source_kind='doc' AND NOT (source_ref = ANY($1::text[]))`,
239
+ [present]
240
+ );
241
+ totals.deleted = del.rowCount || 0;
242
+ await client.query('COMMIT');
243
+ } else {
244
+ console.error(`search-ingest-md: vanished-source prune SKIPPED — ${failures.length} file(s) failed, so the present-sources list is incomplete`);
200
245
  }
201
- // Delete chunks for doc sources that no longer exist in the tree.
202
- const present = allRows.map((f) => f.sourceRef);
203
- const del = await client.query(
204
- `DELETE FROM search_chunks WHERE source_kind='doc' AND NOT (source_ref = ANY($1::text[]))`,
205
- [present]
206
- );
207
- totals.deleted = del.rowCount || 0;
208
- await client.query('COMMIT');
209
246
  } catch (e) {
210
247
  await client.query('ROLLBACK').catch(() => {});
211
- console.error('search-ingest-md: FAILED, rolled back:', e.message);
212
- process.exitCode = 1;
213
- client.release();
214
- await pool.end().catch(() => {});
215
- return;
248
+ failures.push({ sourceRef: '(vanished-source prune)', message: errLine(e) });
249
+ console.error('search-ingest-md: vanished-source prune FAILED:', errLine(e));
216
250
  }
217
251
  client.release();
218
252
  await pool.end().catch(() => {});
219
253
  console.log(`search-ingest-md: inserted=${totals.inserted} updated=${totals.updated} unchanged=${totals.unchanged} pruned=${totals.pruned} deleted_sources_chunks=${totals.deleted}`);
254
+ if (failures.length > 0) {
255
+ // NON-SILENT (task 1003097's sibling half): the deploy step runs this
256
+ // best-effort, so a non-zero exit is the only signal that reaches a log a
257
+ // human reads. Name every casualty — "some files were skipped" is what let
258
+ // this hide.
259
+ console.error(`search-ingest-md: ${failures.length} file(s) FAILED and were skipped:`);
260
+ for (const f of failures) console.error(` ${f.sourceRef}: ${f.message}`);
261
+ process.exitCode = 1;
262
+ }
220
263
  }
221
264
 
222
265
  if (require.main === module) {
package/src/module-api.js CHANGED
@@ -963,8 +963,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
963
963
  // landed since 1.19.266 with no explicit bump. run 32404853216. (task 1002620)
964
964
  // 1.19.268 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
965
965
  // landed since 1.19.267 with no explicit bump. run 32407127474. (task 1002620)
966
+ // 1.19.269 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
967
+ // landed since 1.19.268 with no explicit bump. run 32427178123. (task 1002620)
966
968
  // ---------------------------------------------------------------------------
967
- const CORE_VERSION = '1.19.268'; // CI auto-patch carrier (ADR 0161); see changelog above
969
+ const CORE_VERSION = '1.19.269'; // CI auto-patch carrier (ADR 0161); see changelog above
968
970
 
969
971
  // A namespaced logger so a module's log lines are attributable + consistent.
970
972
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,193 @@
1
+ // tests/search_ingest_nul.mjs — task 1002859.
2
+ //
3
+ // THE OUTAGE: /recall returned zero matches for EVERY query on live (task
4
+ // 1003097), because the doc index was never written. Postgres rejects U+0000 in
5
+ // a text column ('invalid byte sequence for encoding "UTF8": 0x00'), both
6
+ // ingesters handed chunk bodies straight to an INSERT, and both wrapped the
7
+ // WHOLE run in ONE transaction. So one NUL byte in one chunk of one file rolled
8
+ // back the entire index update — and the deploy's search-index step logs
9
+ // non-fatally, so nothing surfaced. A corpus that starts empty (a restore leaves
10
+ // the table empty and expects the ingest to rebuild it) then stays empty
11
+ // forever, and the route answers a legitimate-looking 200 with no results.
12
+ //
13
+ // Same bug class had already been fixed once on a different path: commit 75742cb
14
+ // (task 1002507) stripped NUL from ship.js snippetOf after a UTF-16LE `wsl
15
+ // --list` read voided a session digest — and left every other path exposed.
16
+ //
17
+ // What we prove here (pure — no DB):
18
+ // 1. The sanitizer strips U+0000, and strips lone surrogates too (the same
19
+ // wrong-encoding cause, also unencodable as UTF-8).
20
+ // 2. It runs at the SHARED funnel (search-chunker), so BOTH ingesters are
21
+ // covered and the guard cannot be added to one path and forgotten on the
22
+ // other — which is exactly how this survived the first fix.
23
+ // 3. Nothing a reader wants is lost: sanitizing is a no-op on clean prose.
24
+ // 4. A NUL in a HEADING or a front-matter title is stripped too — title and
25
+ // heading_path are Postgres text columns as well, so sanitizing only the
26
+ // body would have left two live vectors.
27
+ // 5. Both ingesters now scope a transaction PER SOURCE, and report failures by
28
+ // name with a non-zero exit — the blast radius of a bad byte is one file,
29
+ // not the whole corpus, and it is no longer silent.
30
+ //
31
+ // Run: node tests/search_ingest_nul.mjs
32
+
33
+ import { strict as assert } from 'node:assert';
34
+ import { createRequire } from 'node:module';
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ const require = createRequire(import.meta.url);
40
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const chunker = require('../modules/memory/search-chunker.js');
42
+ const { chunkMarkdown, sanitizeForText } = chunker;
43
+
44
+ // Never write a literal control character into a source file — build it.
45
+ const NUL = String.fromCharCode(0);
46
+
47
+ let passed = 0;
48
+ let failed = 0;
49
+ function test(name, fn) {
50
+ try { fn(); passed++; console.log(` ok ${name}`); }
51
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
52
+ }
53
+
54
+ // ---- 1. the sanitizer ----
55
+
56
+ test('sanitizeForText strips U+0000', () => {
57
+ assert.equal(sanitizeForText(`a${NUL}b`), 'ab');
58
+ assert.equal(sanitizeForText(`${NUL}${NUL}lead`), 'lead');
59
+ assert.equal(sanitizeForText(`trail${NUL}`), 'trail');
60
+ });
61
+
62
+ test('sanitizeForText strips LONE surrogates but keeps valid pairs', () => {
63
+ // Unpaired halves cannot be encoded as UTF-8 — same cause (a wrong-encoding
64
+ // read), same Postgres rejection.
65
+ assert.equal(sanitizeForText('a\uD800b'), 'ab', 'lone high surrogate');
66
+ assert.equal(sanitizeForText('a\uDC00b'), 'ab', 'lone low surrogate');
67
+ // A real astral character is a VALID pair and must survive untouched.
68
+ assert.equal(sanitizeForText('emoji \u{1F600} ok'), 'emoji \u{1F600} ok');
69
+ });
70
+
71
+ test('sanitizeForText is a no-op on clean prose and non-strings', () => {
72
+ const clean = '# Title\n\nSome prose with `code`, an emoji \u{1F680}, and accents: éàü.\n';
73
+ assert.equal(sanitizeForText(clean), clean);
74
+ assert.equal(sanitizeForText(null), null);
75
+ assert.equal(sanitizeForText(undefined), undefined);
76
+ assert.equal(sanitizeForText(42), 42);
77
+ });
78
+
79
+ // ---- 2/4. it runs at the shared funnel, and covers title + heading too ----
80
+
81
+ test('chunkMarkdown strips NUL from body, title AND heading_path', () => {
82
+ const content = `# Ti${NUL}tle\n\n## Sec${NUL}tion\n\nbo${NUL}dy prose here, long enough to be a chunk.\n`;
83
+ const { title, chunks } = chunkMarkdown({ path: 'docs/x.md', content });
84
+ assert.ok(chunks.length > 0, 'produced at least one chunk');
85
+ assert.equal(title.includes(NUL), false, `title still carries NUL: ${JSON.stringify(title)}`);
86
+ for (const c of chunks) {
87
+ assert.equal(c.body.includes(NUL), false, 'body still carries NUL');
88
+ assert.equal(String(c.heading_path || '').includes(NUL), false, 'heading_path still carries NUL');
89
+ }
90
+ });
91
+
92
+ test('a front-matter title carrying NUL is sanitized', () => {
93
+ const content = `---\ntitle: Fr${NUL}ont\n---\n\nsome body prose here for a chunk.\n`;
94
+ const { title } = chunkMarkdown({ path: 'docs/y.md', content });
95
+ assert.equal(title.includes(NUL), false);
96
+ assert.equal(title, 'Front');
97
+ });
98
+
99
+ test('the guard lives at the SHARED funnel, so both ingesters inherit it', () => {
100
+ // The first fix of this bug class (task 1002507) was local to ship.js and left
101
+ // every other path exposed. Assert structurally that neither ingester relies
102
+ // on its own private guard: both must reach the sanitizer through chunkMarkdown.
103
+ for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
104
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
105
+ assert.match(src, /chunkMarkdown\(/, `${rel} must chunk through the shared funnel`);
106
+ }
107
+ const chunkerSrc = fs.readFileSync(path.join(ROOT, 'modules/memory/search-chunker.js'), 'utf8');
108
+ assert.match(chunkerSrc, /sanitizeForText\(content\)/, 'the funnel must sanitize its INPUT, before any parsing');
109
+ });
110
+
111
+ test('chunkFile (the md ingester\'s row builder) emits NUL-free rows', () => {
112
+ const ingest = require('../scripts/gds/search-ingest-md.js');
113
+ const tmp = fs.mkdtempSync(path.join(ROOT, '.tmp-nul-'));
114
+ try {
115
+ const f = path.join(tmp, 'poisoned.md');
116
+ fs.writeFileSync(f, `# He${NUL}ad\n\nbo${NUL}dy text long enough to survive chunking.\n`);
117
+ const rows = ingest.chunkFile(f);
118
+ assert.ok(rows.length > 0);
119
+ for (const r of rows) {
120
+ assert.equal(r.body.includes(NUL), false, 'row body must be clean before the INSERT');
121
+ assert.equal(String(r.title || '').includes(NUL), false);
122
+ assert.equal(String(r.heading_path || '').includes(NUL), false);
123
+ assert.match(r.content_hash, /^[0-9a-f]{64}$/, 'hash is of the SANITIZED body');
124
+ }
125
+ } finally {
126
+ fs.rmSync(tmp, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ // ---- 5. blast radius + non-silence ----
131
+
132
+ test('both ingesters scope a transaction PER SOURCE, not one per run', () => {
133
+ // This is the "rolls back the entire re-index" half of the bug. A single BEGIN
134
+ // before the loop is what turned one bad byte into a total outage.
135
+ for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
136
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
137
+ // Anchor on the loop that actually WRITES (the one calling ingestFile /
138
+ // ingestRows), not the first `for (const f of ...)` in the file — the
139
+ // dry-run sample listing uses that shape too.
140
+ const writeIdx = src.search(/await ingest(File|Rows)\(client/); // the CALL, not the definition
141
+ assert.ok(writeIdx > 0, `${rel}: found the write call`);
142
+ const loopIdx = src.lastIndexOf('for (', writeIdx);
143
+ assert.ok(loopIdx > 0 && writeIdx - loopIdx < 400, `${rel}: the write is inside a loop`);
144
+ const loopBody = src.slice(loopIdx, writeIdx + 700);
145
+ assert.match(loopBody, /BEGIN/, `${rel}: BEGIN must be INSIDE the per-source loop`);
146
+ assert.match(loopBody, /COMMIT/, `${rel}: COMMIT must be inside the loop too`);
147
+ assert.match(loopBody, /ROLLBACK/, `${rel}: a failing source rolls back only itself`);
148
+ }
149
+ });
150
+
151
+ test('a skipped source is named and forces a non-zero exit', () => {
152
+ // The deploy/cron step runs these best-effort, so the exit code is the only
153
+ // signal that reaches a log a human reads. "Some files were skipped" is what
154
+ // let this hide for weeks — every casualty must be named.
155
+ for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
156
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
157
+ assert.match(src, /failures\.push\(/, `${rel}: collects casualties`);
158
+ assert.match(src, /FAILED and were skipped/, `${rel}: reports them as a group`);
159
+ assert.match(src, /for \(const f of failures\) console\.error/, `${rel}: names each one`);
160
+ assert.match(src, /process\.exitCode = 1/, `${rel}: exits non-zero so the deploy log shows it`);
161
+ }
162
+ });
163
+
164
+ test('the vanished-source prune is SKIPPED after any failure', () => {
165
+ // present/presentRefs is the list we MEANT to write. Pruning against it on a
166
+ // partial run would delete chunks for a source that only failed transiently —
167
+ // turning a recoverable skip into data loss.
168
+ for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
169
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
170
+ assert.match(src, /failures\.length === 0/, `${rel}: prune is gated on a clean run`);
171
+ assert.match(src, /prune SKIPPED/, `${rel}: and says so when it skips`);
172
+ }
173
+ });
174
+
175
+ test('the driver error text reaching a log is bounded and single-line', () => {
176
+ // Panel finding: a Postgres error message is unbounded upstream text and CAN
177
+ // embed row content (a constraint violation quotes the offending key), and
178
+ // these are deploy/cron log sinks. The DB corpus makes it matter more — its
179
+ // prose is session-log/memory-derived text the file treats as scrub-sensitive.
180
+ for (const rel of ['scripts/gds/search-ingest-md.js', 'scripts/gds/search-ingest-db.js']) {
181
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
182
+ assert.match(src, /const ERR_LOG_MAX = \d+;/, `${rel}: declares a cap`);
183
+ assert.match(src, /function errLine\(e\)/, `${rel}: has the single formatter`);
184
+ assert.match(src, /truncated/, `${rel}: says when it truncated`);
185
+ // And nothing may still interpolate the raw driver message directly.
186
+ assert.doesNotMatch(src, /e && e\.message \? e\.message : (e|String\(e\))/,
187
+ `${rel}: every error path must go through errLine`);
188
+ }
189
+ });
190
+
191
+ console.log('');
192
+ console.log(`search_ingest_nul: ${passed} passed, ${failed} failed`);
193
+ process.exit(failed === 0 ? 0 : 1);