@remnic/core 10.0.56 → 10.0.58

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 (38) hide show
  1. package/dist/access-cli.js +8 -8
  2. package/dist/access-http-query.js +2 -2
  3. package/dist/access-http.js +3 -3
  4. package/dist/{chunk-FCAXGYDT.js → chunk-CTNDFKEX.js} +2 -2
  5. package/dist/{chunk-CC2ESKHP.js → chunk-E2YAGEFR.js} +7 -7
  6. package/dist/{chunk-CZS7UTXL.js → chunk-KCTL5EY7.js} +2 -2
  7. package/dist/{chunk-5C44AMRV.js → chunk-L5UB5BIY.js} +2 -2
  8. package/dist/{chunk-S3OI5DP5.js → chunk-P5FYZH5G.js} +235 -124
  9. package/dist/chunk-P5FYZH5G.js.map +1 -0
  10. package/dist/{chunk-OGR52N7A.js → chunk-W2KH27MQ.js} +3 -3
  11. package/dist/{chunk-GMRP3HN3.js → chunk-W3R77WHT.js} +2 -2
  12. package/dist/{chunk-VZ7VJGPL.js → chunk-W4QTA2H5.js} +2 -2
  13. package/dist/cli.js +6 -6
  14. package/dist/index.js +8 -8
  15. package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
  16. package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
  17. package/dist/namespaces/catalog-repair.d.ts +35 -0
  18. package/dist/namespaces/catalog-repair.d.ts.map +1 -0
  19. package/dist/namespaces/catalog.d.ts +31 -27
  20. package/dist/namespaces/catalog.d.ts.map +1 -1
  21. package/dist/operator-toolkit.js +3 -3
  22. package/dist/orchestration/maintenance.js +3 -3
  23. package/dist/orchestrator.js +8 -8
  24. package/package.json +2 -2
  25. package/src/namespaces/catalog-read-boundary.test.ts +121 -0
  26. package/src/namespaces/catalog-read-boundary.ts +159 -0
  27. package/src/namespaces/catalog-repair.ts +69 -0
  28. package/src/namespaces/catalog.test.ts +493 -10
  29. package/src/namespaces/catalog.ts +112 -110
  30. package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
  31. package/dist/chunk-S3OI5DP5.js.map +0 -1
  32. /package/dist/{chunk-FCAXGYDT.js.map → chunk-CTNDFKEX.js.map} +0 -0
  33. /package/dist/{chunk-CC2ESKHP.js.map → chunk-E2YAGEFR.js.map} +0 -0
  34. /package/dist/{chunk-CZS7UTXL.js.map → chunk-KCTL5EY7.js.map} +0 -0
  35. /package/dist/{chunk-5C44AMRV.js.map → chunk-L5UB5BIY.js.map} +0 -0
  36. /package/dist/{chunk-OGR52N7A.js.map → chunk-W2KH27MQ.js.map} +0 -0
  37. /package/dist/{chunk-GMRP3HN3.js.map → chunk-W3R77WHT.js.map} +0 -0
  38. /package/dist/{chunk-VZ7VJGPL.js.map → chunk-W4QTA2H5.js.map} +0 -0
@@ -9,6 +9,7 @@ import {
9
9
  readdir,
10
10
  realpath,
11
11
  rename,
12
+ rm,
12
13
  stat,
13
14
  writeFile,
14
15
  } from "node:fs/promises";
@@ -23,6 +24,12 @@ import { namespaceIdentityFromToken, namespaceIdentityLegacyToken, namespaceIden
23
24
  import { resolveDefaultNamespaceRoot, resolveNamespaceStorageRoot } from "./storage.js";
24
25
  import { ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
25
26
  import { isNotFoundError } from "../utils/errno.js";
27
+ import {
28
+ dropDuplicateStorageRootAliases,
29
+ sanitizeRecordForRead,
30
+ type CatalogReadBoundaryPort,
31
+ } from "./catalog-read-boundary.js";
32
+ import { MalformedCatalogRepairGate, malformedSidecarPath } from "./catalog-repair.js";
26
33
 
27
34
  /**
28
35
  * Rebuildable namespace catalog (issue #1499).
@@ -90,6 +97,17 @@ export interface NamespaceCatalogFilter {
90
97
  /** Out-parameter a catalog read fills in when it could not see the whole catalog. */
91
98
  interface CatalogReadHealth {
92
99
  incomplete?: string;
100
+ /**
101
+ * Set ONLY when the file was read to the end and malformed rows were skipped. An unreadable
102
+ * catalog also sets `incomplete` but never `malformed`: its empty result is not the catalog's
103
+ * content, so nothing may be rewritten from it.
104
+ */
105
+ malformed?: boolean;
106
+ /**
107
+ * The raw malformed lines the read skipped, in file order. Only filled when `malformed` is
108
+ * set; the repair quarantines exactly these into its sidecar before rewriting.
109
+ */
110
+ malformedLines?: string[];
93
111
  }
94
112
 
95
113
  export interface NamespaceCatalogReadOptions {
@@ -346,7 +364,7 @@ function laterIso(a: string | undefined, b: string | undefined): string | undefi
346
364
  * rebuilt record and left untouched — we only recover touch recency that a
347
365
  * concurrent (possibly cross-process) writer recorded after our initial load.
348
366
  */
349
- function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord {
367
+ export function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord {
350
368
  const merged: NamespaceRecord = { ...base };
351
369
  const lr = laterIso(base.lastReadAt, fresh.lastReadAt);
352
370
  if (lr) merged.lastReadAt = lr;
@@ -496,6 +514,14 @@ export class NamespaceCatalog {
496
514
  private readonly compactBytesLimit: number;
497
515
  private readonly readCoalesceMs: number;
498
516
  private readonly writeCoalesceMs: number;
517
+ // #3298 review: single-flight + backoff gate for the malformed-catalog repair.
518
+ private readonly malformedRepairGate = new MalformedCatalogRepairGate();
519
+
520
+ /** Test-only observability: how many repair attempts actually entered the gate. */
521
+ get malformedRepairAttemptsForTest(): number {
522
+ return this.malformedRepairGate.attemptCount;
523
+ }
524
+
499
525
  // Size after the most recent auto-compaction (0 = none yet). Hysteresis for
500
526
  // maybeAutoCompact: a folded catalog that is itself above the limit (many
501
527
  // distinct namespaces) would otherwise be re-folded + re-rewritten on every
@@ -538,122 +564,31 @@ export class NamespaceCatalog {
538
564
 
539
565
  // ── Public enumeration API ──────────────────────────────────────────────
540
566
 
541
- /**
542
- * Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
543
- * P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
544
- * schema checks only, so a tampered or pre-fix row could surface unsafe data to
545
- * maintenance/QMD until a rewrite occurs. Two distinct defenses:
546
- *
547
- * 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
548
- * (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
549
- * — return `null` so the caller drops it. The disk SCAN and the hot touch
550
- * path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
551
- * gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
552
- * would expose a namespace those paths reject (note `isStorageDirForNamespace`
553
- * can still build a tokenized root even for `../evil`, so storageDir sanitation
554
- * alone does not catch it). The default namespace is exempt (it may be a
555
- * non-route literal), matching every other validation site.
556
- *
557
- * 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
558
- * as the write path — full containment (`isContainedStorageDir`: lexical +
559
- * symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
560
- * a record fails EITHER check we substitute the trusted resolved-and-safe root
561
- * for that namespace (rule 42: read and write stay symmetric).
562
- */
563
- private async sanitizeRecordForRead(record: NamespaceRecord): Promise<NamespaceRecord | null> {
564
- // Defense 1: drop an unsafe non-default namespace name entirely. Compare
565
- // against the NORMALIZED default identity — record keys are trimmed, so a raw
566
- // whitespace-padded config default would never match the default row (NH-FH).
567
- if (record.namespace !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(record.namespace)) {
568
- return null;
569
- }
570
- // Defense 2: keep the record but substitute a safe storageDir when needed.
571
- if (
572
- (await this.isContainedStorageDir(record.storageDir)) &&
573
- (await this.isStorageDirForNamespace(record.namespace, record.storageDir))
574
- ) {
575
- return record;
576
- }
577
- const safe = await this.resolveSafeStorageDir(record.namespace);
578
- return { ...record, storageDir: safe };
579
- }
580
-
581
- private storageRootOwnershipRank(
582
- record: NamespaceRecord,
583
- resolvedStorageDir: string,
584
- configured: Set<string>,
585
- ): number {
586
- if (resolvedStorageDir === path.resolve(this.memoryDir)) {
587
- return record.namespace === this.defaultNamespaceIdentity ? 0 : 3;
588
- }
589
-
590
- const leaf = path.basename(resolvedStorageDir);
591
- const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
592
- if (tokenOwnsRoot && configured.has(record.namespace)) {
593
- return 0;
594
- }
595
- if (record.namespace === leaf) return 1;
596
- if (tokenOwnsRoot) return 2;
597
- return 3;
598
- }
599
-
600
- private configuredNamespaceIdentities(): Set<string> {
601
- return new Set(
602
- [
603
- this.config.defaultNamespace,
604
- this.config.sharedNamespace,
605
- ...this.config.namespacePolicies.map((p) => p.name),
606
- ]
607
- .map((n) => normalizeNamespaceIdentity(n))
608
- .filter((n) => n.length > 0),
609
- );
610
- }
611
-
612
- private preferStorageRootOwner(
613
- current: NamespaceRecord,
614
- candidate: NamespaceRecord,
615
- resolvedStorageDir: string,
616
- configured: Set<string>,
617
- ): NamespaceRecord {
618
- const currentRank = this.storageRootOwnershipRank(current, resolvedStorageDir, configured);
619
- const candidateRank = this.storageRootOwnershipRank(candidate, resolvedStorageDir, configured);
620
- if (candidateRank < currentRank) return candidate;
621
- if (candidateRank > currentRank) return current;
622
-
623
- const byName = candidate.namespace.localeCompare(current.namespace);
624
- if (byName < 0) return candidate;
625
- if (byName > 0) return current;
626
- return candidate.identityToken.localeCompare(current.identityToken) < 0 ? candidate : current;
567
+ private catalogReadBoundaryPort(): CatalogReadBoundaryPort {
568
+ return {
569
+ defaultNamespaceIdentity: this.defaultNamespaceIdentity,
570
+ memoryDir: this.memoryDir,
571
+ isContainedStorageDir: (candidate) => this.isContainedStorageDir(candidate),
572
+ isStorageDirForNamespace: (namespace, candidate) => this.isStorageDirForNamespace(namespace, candidate),
573
+ resolveSafeStorageDir: (namespace) => this.resolveSafeStorageDir(namespace),
574
+ };
627
575
  }
628
576
 
629
- private dropDuplicateStorageRootAliases(records: NamespaceRecord[]): NamespaceRecord[] {
630
- const byStorageDir = new Map<string, NamespaceRecord>();
631
- const configured = this.configuredNamespaceIdentities();
632
- for (const record of records) {
633
- const resolvedStorageDir = path.resolve(record.storageDir);
634
- const current = byStorageDir.get(resolvedStorageDir);
635
- if (!current) {
636
- byStorageDir.set(resolvedStorageDir, record);
637
- continue;
638
- }
639
- const owner = this.preferStorageRootOwner(current, record, resolvedStorageDir, configured);
640
- const alias = owner === current ? record : current;
641
- byStorageDir.set(resolvedStorageDir, mergeNewerTouchFields(owner, alias));
642
- }
643
- return [...byStorageDir.values()];
644
- }
645
577
  private loadSanitizedRecords(health?: CatalogReadHealth): Promise<NamespaceRecord[]> {
646
578
  return this.queueCritical(async () => {
647
579
  const records = await this.loadCompacted(false, health);
580
+ const port = this.catalogReadBoundaryPort();
648
581
  const sanitized = await Promise.all(
649
- [...records.values()].map((r) => this.sanitizeRecordForRead(r)),
582
+ [...records.values()].map((r) => sanitizeRecordForRead(port, r)),
650
583
  );
651
584
  // Drop unsafe-namespace rows (sanitizer returned null) at the read boundary.
652
585
  // Then collapse duplicate root aliases so maintenance/QMD see exactly one
653
586
  // namespace owner for a physical storage root, matching rebuild ownership,
654
587
  // while preserving touch recency from every alias row.
655
- return this.dropDuplicateStorageRootAliases(
588
+ return dropDuplicateStorageRootAliases(
589
+ port,
656
590
  sanitized.filter((r): r is NamespaceRecord => r !== null),
591
+ this.config,
657
592
  );
658
593
  });
659
594
  }
@@ -665,7 +600,16 @@ export class NamespaceCatalog {
665
600
  if (!this.enabled) return [];
666
601
  const health: CatalogReadHealth = {};
667
602
  let out = await this.loadSanitizedRecords(health);
668
- if (health.incomplete !== undefined) options?.onIncomplete?.(health.incomplete);
603
+ if (health.incomplete !== undefined) {
604
+ options?.onIncomplete?.(health.incomplete);
605
+ }
606
+ if (health.malformed === true) {
607
+ // A permanently malformed row keeps every completion request owed until the catalog is
608
+ // rewritten, so repair it now: fold the readable rows and rewrite the log without the bad
609
+ // ones. Best-effort and fail-open like the size-triggered compaction. Only a read that
610
+ // reached the end of the file may trigger it (see CatalogReadHealth.malformed).
611
+ void this.malformedRepairGate.run(() => this.rewriteAfterIncompleteRead()).catch(() => undefined);
612
+ }
669
613
  if (filter?.kind) out = out.filter((r) => r.kind === filter.kind);
670
614
  if (filter?.discoveredBy) out = out.filter((r) => r.discoveredBy === filter.discoveredBy);
671
615
  if (filter?.writtenSince) {
@@ -1424,6 +1368,47 @@ export class NamespaceCatalog {
1424
1368
  );
1425
1369
  }
1426
1370
 
1371
+ /**
1372
+ * Rewrite the catalog after a read skipped malformed rows (#3297). Same fold-and-rewrite as the
1373
+ * size-triggered compaction — the readable rows survive, the malformed ones do not — so a stuck
1374
+ * bad row stops keeping every completion request owed. Best-effort and fail-open (rule #40): a
1375
+ * failed rewrite leaves the catalog unchanged and the next read reports and retries.
1376
+ *
1377
+ * Locking: the fold runs inside `queueCritical` (in-process serialization, like every other
1378
+ * read), and the rewrite's load→rename window runs under the held catalog file lock, so it can
1379
+ * never interleave with another process's touch append or rebuild. If another process already
1380
+ * holds the lock, the rewrite is dropped (not queued): that process's own rewrite or rebuild is
1381
+ * the likeliest repair, and the next incomplete read retries anyway.
1382
+ *
1383
+ * Data safety: the locked re-read must itself reach the end of the file and still find
1384
+ * malformed rows, and must leave at least one readable row. A failed re-read folds to an empty
1385
+ * map, and a catalog with nothing readable is left for `rebuildFromDisk`: rewriting either
1386
+ * would replace a healthy or recoverable catalog with an empty file.
1387
+ */
1388
+ private async rewriteAfterIncompleteRead(): Promise<"repaired" | "dropped" | "failed"> {
1389
+ return this.queueCritical(async () =>
1390
+ this.withHeldCatalogLock(async (acquired) => {
1391
+ if (!acquired) return "dropped";
1392
+ const health: CatalogReadHealth = { malformedLines: [] };
1393
+ const folded = await this.loadCompacted(true, health);
1394
+ if (health.malformed !== true || folded.size === 0) return "failed";
1395
+ // Quarantine the rows the rewrite drops before the rename, so nothing is lost
1396
+ // silently. Best-effort like the repair itself: a failed sidecar write must not
1397
+ // leave a stuck malformed catalog unrepaired.
1398
+ const dropped = health.malformedLines ?? [];
1399
+ if (dropped.length > 0) {
1400
+ // Hash-named (#3298 review): repeated attempts over the same bad rows
1401
+ // reuse one sidecar instead of piling one per failed attempt.
1402
+ const sidecar = malformedSidecarPath(this.catalogPath, dropped);
1403
+ await writeFile(sidecar, dropped.join("\n") + "\n", "utf8").catch(() => undefined);
1404
+ }
1405
+ await this.rewriteUnchained([...folded.values()]);
1406
+ return "repaired";
1407
+ }),
1408
+ );
1409
+ }
1410
+
1411
+
1427
1412
  /**
1428
1413
  * Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
1429
1414
  * (issue #1903). MUST be called only from inside `touch`'s held-lock +
@@ -2132,7 +2117,12 @@ export class NamespaceCatalog {
2132
2117
  this.compactedCache.identity.ino === identity.ino
2133
2118
  ) {
2134
2119
  await handle.close().catch(() => undefined);
2135
- if (health && this.compactedCache.incomplete) health.incomplete = this.compactedCache.incomplete;
2120
+ if (health && this.compactedCache.incomplete) {
2121
+ health.incomplete = this.compactedCache.incomplete;
2122
+ // Invariant: cache.incomplete is set ONLY by the malformed branch below, so
2123
+ // deriving `malformed` here cannot misfire for an unreadable catalog.
2124
+ health.malformed = true;
2125
+ }
2136
2126
  return this.cloneCompactedRecords(this.compactedCache.records);
2137
2127
  }
2138
2128
  } catch {
@@ -2167,11 +2157,13 @@ export class NamespaceCatalog {
2167
2157
  // Skip corrupt lines (CLAUDE.md rule #18 robustness), but count them so a caller that
2168
2158
  // needs the whole catalog can tell the read was incomplete (issue #3293).
2169
2159
  malformed += 1;
2160
+ health?.malformedLines?.push(trimmed);
2170
2161
  return;
2171
2162
  }
2172
2163
  const record = coerceRecord(parsed);
2173
2164
  if (!record) {
2174
2165
  malformed += 1;
2166
+ health?.malformedLines?.push(trimmed);
2175
2167
  return;
2176
2168
  }
2177
2169
  const prior = records.get(record.namespace);
@@ -2203,7 +2195,10 @@ export class NamespaceCatalog {
2203
2195
  }
2204
2196
 
2205
2197
  const incomplete = malformed > 0 ? `${malformed} malformed catalog line(s) skipped` : undefined;
2206
- if (health && incomplete) health.incomplete = incomplete;
2198
+ if (health && incomplete) {
2199
+ health.incomplete = incomplete;
2200
+ health.malformed = true;
2201
+ }
2207
2202
  this.compactedCache = identity ? { identity, records, incomplete } : undefined;
2208
2203
  return this.cloneCompactedRecords(records);
2209
2204
  }
@@ -2269,8 +2264,15 @@ export class NamespaceCatalog {
2269
2264
  const body = records.map((r) => serializeRecord(r)).join("\n") + (records.length > 0 ? "\n" : "");
2270
2265
  await mkdir(this.stateDir, { recursive: true });
2271
2266
  const tmp = `${this.catalogPath}.${process.pid}.${Date.now()}.tmp`;
2272
- await writeFile(tmp, body, "utf8");
2273
- await rename(tmp, this.catalogPath);
2267
+ try {
2268
+ await writeFile(tmp, body, "utf8");
2269
+ await rename(tmp, this.catalogPath);
2270
+ } catch (error) {
2271
+ // A failed write or rename (for example the catalog path is a directory) must not leave
2272
+ // the temp file behind; the original error still surfaces to the caller.
2273
+ await rm(tmp, { force: true }).catch(() => undefined);
2274
+ throw error;
2275
+ }
2274
2276
  await this.refreshCacheIdentity(new Map(records.map((record) => [record.namespace, record])));
2275
2277
  }
2276
2278
 
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Lifecycle matrix subject for the namespace catalog's repair after an incomplete read (#3297).
3
+ * A catalog with a malformed row is read through the public `listNamespaces` API: the read
4
+ * reports the incomplete catalog and schedules a rewrite that drops the bad row, so a fresh
5
+ * instance over the persisted file and a repeated read both see a whole catalog.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { appendFile, readFile, rm } from "node:fs/promises";
9
+ import path from "node:path";
10
+
11
+ import { NamespaceCatalog } from "../../namespaces/catalog.js";
12
+ import type { PluginConfig } from "../../types.js";
13
+ import { type LifecycleSubject, type MatrixRow, runLifecycleMatrix } from "../lifecycle-matrix.js";
14
+ import { mkTempMemoryDir } from "../orchestrator-lite.js";
15
+
16
+ const BAD_ROW = "{this is not json";
17
+
18
+ interface CatalogReadRepairState {
19
+ dir: string;
20
+ catalogPath: string;
21
+ catalog: NamespaceCatalog;
22
+ firstReasons: string[];
23
+ firstNamespaces: string[];
24
+ secondReasons?: string[];
25
+ afterRepair?: string;
26
+ afterSecondRead?: string;
27
+ }
28
+
29
+ /** Fixture config: the catalog reads only memoryDir, the namespace names and the enable flags. */
30
+ function catalogConfig(memoryDir: string): PluginConfig {
31
+ return Object.assign(Object.create(null), {
32
+ memoryDir,
33
+ namespacesEnabled: true,
34
+ namespaceCatalogEnabled: true,
35
+ defaultNamespace: "default",
36
+ sharedNamespace: "shared",
37
+ namespacePolicies: [],
38
+ });
39
+ }
40
+
41
+ /** Wait until the scheduled rewrite has removed the bad row (the read does not await it). */
42
+ async function waitForRepair(catalogPath: string): Promise<string> {
43
+ const deadline = Date.now() + 2_000;
44
+ for (;;) {
45
+ const text = await readFile(catalogPath, "utf8");
46
+ if (!text.includes(BAD_ROW) || Date.now() >= deadline) {
47
+ // Let the rewrite finish its rename and release the catalog lock before anyone reads again.
48
+ await new Promise((resolve) => setTimeout(resolve, 50));
49
+ return readFile(catalogPath, "utf8");
50
+ }
51
+ await new Promise((resolve) => setTimeout(resolve, 20));
52
+ }
53
+ }
54
+
55
+ const subject: LifecycleSubject<CatalogReadRepairState> = {
56
+ appliesTo(row: MatrixRow): boolean | string {
57
+ if (row.id === "restart-reload-recovery") return true;
58
+ if (row.id === "dedupe-replay") return true;
59
+ return "catalog repair applies to persisted-state reload and repeated reads only";
60
+ },
61
+
62
+ async setup(row: MatrixRow): Promise<CatalogReadRepairState> {
63
+ const dir = await mkTempMemoryDir(`catalog-read-repair-${row.id}`);
64
+ try {
65
+ const catalog = new NamespaceCatalog(catalogConfig(dir));
66
+ await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
67
+ const catalogPath = path.join(dir, "state", "namespaces.jsonl");
68
+ await appendFile(catalogPath, `${BAD_ROW}\n`, "utf8");
69
+ return { dir, catalogPath, catalog, firstReasons: [], firstNamespaces: [] };
70
+ } catch (error) {
71
+ await rm(dir, { recursive: true, force: true });
72
+ throw error;
73
+ }
74
+ },
75
+
76
+ async exercise(state: CatalogReadRepairState, row: MatrixRow): Promise<void> {
77
+ const first = await state.catalog.listNamespaces(undefined, {
78
+ onIncomplete: (reason) => state.firstReasons.push(reason),
79
+ });
80
+ state.firstNamespaces = first.map((record) => record.namespace);
81
+ state.afterRepair = await waitForRepair(state.catalogPath);
82
+
83
+ if (row.dimensions.restart) {
84
+ // A fresh instance over the persisted (repaired) file.
85
+ const restarted = new NamespaceCatalog(catalogConfig(state.dir));
86
+ state.secondReasons = [];
87
+ const reasons = state.secondReasons;
88
+ await restarted.listNamespaces(undefined, { onIncomplete: (reason) => reasons.push(reason) });
89
+ }
90
+
91
+ if (row.dimensions.dedupeOrReplay) {
92
+ // The same instance reads again: nothing to report and no second rewrite.
93
+ state.secondReasons = [];
94
+ const reasons = state.secondReasons;
95
+ await state.catalog.listNamespaces(undefined, { onIncomplete: (reason) => reasons.push(reason) });
96
+ await new Promise((resolve) => setTimeout(resolve, 100));
97
+ state.afterSecondRead = await readFile(state.catalogPath, "utf8");
98
+ }
99
+ },
100
+
101
+ async invariants(state: CatalogReadRepairState, row: MatrixRow): Promise<void> {
102
+ assert.equal(state.firstReasons.length, 1, `${row.id}: the first read reports the malformed row`);
103
+ assert.deepEqual(
104
+ state.firstNamespaces,
105
+ ["project-origin-abc123"],
106
+ `${row.id}: the readable record is still returned`,
107
+ );
108
+ assert.ok(
109
+ !(state.afterRepair ?? "").includes(BAD_ROW),
110
+ `${row.id}: the scheduled rewrite dropped the malformed row`,
111
+ );
112
+ assert.match(state.afterRepair ?? "", /project-origin-abc123/, `${row.id}: the readable row survived the rewrite`);
113
+ assert.deepEqual(state.secondReasons, [], `${row.id}: after the repair the catalog reads whole`);
114
+ if (row.dimensions.dedupeOrReplay) {
115
+ assert.equal(state.afterSecondRead, state.afterRepair, `${row.id}: a clean read does not rewrite again`);
116
+ }
117
+ },
118
+
119
+ async teardown(state: CatalogReadRepairState): Promise<void> {
120
+ await rm(state.dir, { recursive: true, force: true });
121
+ },
122
+ };
123
+
124
+ runLifecycleMatrix("catalog-read-repair", subject);