@remnic/core 9.7.11 → 9.7.13
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/dist/access-admin-ops-surface.d.ts +6 -6
- package/dist/access-admin-ops-surface.js +7 -7
- package/dist/access-boundary.d.ts +6 -6
- package/dist/access-boundary.js +8 -8
- package/dist/access-cli.js +31 -31
- package/dist/access-http.d.ts +6 -6
- package/dist/access-http.js +12 -12
- package/dist/access-identity-continuity-surface.d.ts +5 -5
- package/dist/access-identity-continuity-surface.js +7 -7
- package/dist/access-lcm-surface.d.ts +6 -6
- package/dist/access-lcm-surface.js +7 -7
- package/dist/access-mcp.d.ts +6 -6
- package/dist/access-mcp.js +11 -11
- package/dist/access-observe-write-surface.d.ts +6 -6
- package/dist/access-observe-write-surface.js +7 -7
- package/dist/access-operations-batch.js +9 -9
- package/dist/access-operations.d.ts +9 -9
- package/dist/access-operations.js +10 -10
- package/dist/access-recall-surface.d.ts +6 -6
- package/dist/access-recall-surface.js +7 -7
- package/dist/access-schema.d.ts +64 -64
- package/dist/{access-service-DSuzikHQ.d.ts → access-service-tHd3eQpd.d.ts} +4 -4
- package/dist/access-service.d.ts +6 -6
- package/dist/access-service.js +7 -7
- package/dist/access-surface-catalog.d.ts +6 -6
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +5 -5
- package/dist/briefing.d.ts +2 -2
- package/dist/briefing.js +3 -3
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +2 -2
- package/dist/bulk-import/index.d.ts +3 -3
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{catalog-BNwjACa5.d.ts → catalog-DEMiw6HZ.d.ts} +48 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +4 -4
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/{chunk-U4KHQTJ6.js → chunk-2AL7N3BH.js} +4 -2
- package/dist/{chunk-U4KHQTJ6.js.map → chunk-2AL7N3BH.js.map} +1 -1
- package/dist/{chunk-L4H6MQ7I.js → chunk-2RSL6YRA.js} +2 -2
- package/dist/{chunk-QHT4W3RU.js → chunk-2V5QI6NQ.js} +41 -9
- package/dist/chunk-2V5QI6NQ.js.map +1 -0
- package/dist/{chunk-JYRFO4EH.js → chunk-2ZPQUTMW.js} +4 -4
- package/dist/{chunk-673NPXE3.js → chunk-3SIC3E65.js} +2 -2
- package/dist/{chunk-OVMS3DCB.js → chunk-675YU5IK.js} +2 -2
- package/dist/{chunk-7LMEY5KZ.js → chunk-6N7R7TR2.js} +34 -1
- package/dist/chunk-6N7R7TR2.js.map +1 -0
- package/dist/{chunk-TGBJ2OMK.js → chunk-BQTSGK5Y.js} +2 -2
- package/dist/{chunk-6LM6PPDX.js → chunk-CE3GMHT4.js} +2 -2
- package/dist/{chunk-YXOAQAH2.js → chunk-CLFY35ED.js} +3 -3
- package/dist/{chunk-HQ7QZJM5.js → chunk-D4TLWDYR.js} +2 -2
- package/dist/{chunk-LQKITFGB.js → chunk-FUUF7FMX.js} +2 -2
- package/dist/{chunk-3ZVFLMFQ.js → chunk-G5PVJJRA.js} +1 -1
- package/dist/chunk-G5PVJJRA.js.map +1 -0
- package/dist/{chunk-MXRMNKSY.js → chunk-I3GKTDM2.js} +3 -3
- package/dist/{chunk-EQF5763Z.js → chunk-IQULRIZM.js} +2 -2
- package/dist/{chunk-W6R43NLA.js → chunk-KQMKF4G2.js} +17 -17
- package/dist/{chunk-DJCKXI4M.js → chunk-NVXQTRBL.js} +2 -2
- package/dist/{chunk-GHVJASQ4.js → chunk-OCY3CRPG.js} +3 -3
- package/dist/{chunk-YLTG6BO4.js → chunk-OIFZII6J.js} +2 -2
- package/dist/{chunk-XF4KOVDK.js → chunk-PA6DMYEK.js} +6 -6
- package/dist/{chunk-3OQ4IZJN.js → chunk-QPZCAVDC.js} +7 -7
- package/dist/{chunk-DLWYAN6A.js → chunk-T6U7HDIY.js} +2 -2
- package/dist/{chunk-JZM5QUTE.js → chunk-TMGK6YE7.js} +171 -9
- package/dist/chunk-TMGK6YE7.js.map +1 -0
- package/dist/{chunk-KXLRMM35.js → chunk-TRH533CA.js} +30 -7
- package/dist/chunk-TRH533CA.js.map +1 -0
- package/dist/{chunk-UTDA43TB.js → chunk-TWGGNUWJ.js} +2 -2
- package/dist/{chunk-SBFDL2EQ.js → chunk-UTZW3X2R.js} +2 -2
- package/dist/{chunk-SECMZACR.js → chunk-XUQUBZ7O.js} +2 -2
- package/dist/{chunk-5U65RIVI.js → chunk-YI7XUXLX.js} +16 -15
- package/dist/chunk-YI7XUXLX.js.map +1 -0
- package/dist/{chunk-7XRUYC6G.js → chunk-Z6WN2KSH.js} +19 -5
- package/dist/chunk-Z6WN2KSH.js.map +1 -0
- package/dist/{chunk-WUDBMLC6.js → chunk-ZC3HG22I.js} +2 -2
- package/dist/{chunk-VCEH5UYS.js → chunk-ZRXSHGXT.js} +2 -2
- package/dist/{cli-DACyuK8-.d.ts → cli-D8Tsa4Sh.d.ts} +5 -5
- package/dist/cli.d.ts +8 -8
- package/dist/cli.js +25 -25
- package/dist/compounding/engine.d.ts +2 -2
- package/dist/compounding/engine.js +3 -3
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +3 -3
- package/dist/consolidation-provenance-check.d.ts +2 -2
- package/dist/consolidation-undo.d.ts +2 -2
- package/dist/contradiction/index.d.ts +2 -2
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +2 -2
- package/dist/entity-retrieval.js +3 -3
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +5 -5
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/importers/index.d.ts +1 -1
- package/dist/in-flight-reads.d.ts +1 -1
- package/dist/index.d.ts +386 -386
- package/dist/index.js +31 -31
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-model-endpoint.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/mcp-memory-inspector-app.d.ts +6 -6
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +2 -2
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +3 -3
- package/dist/namespaces/migrate.js +4 -4
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/storage.d.ts +3 -3
- package/dist/namespaces/storage.js +3 -3
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +2 -2
- package/dist/operator-toolkit.js +8 -8
- package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +5 -5
- package/dist/{orchestrator-D83AvrPf.d.ts → orchestrator-B_WXBmuE.d.ts} +4 -4
- package/dist/orchestrator.d.ts +5 -5
- package/dist/orchestrator.js +31 -31
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/{pipeline-BSqGpYl1.d.ts → pipeline-Dvtvt141.d.ts} +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/{qmd-CutnpRVe.d.ts → qmd-DwBLh_hl.d.ts} +1 -1
- package/dist/qmd-preflight.d.ts +2 -2
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +2 -2
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-timings.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/replay/normalizers/chatgpt.d.ts +1 -1
- package/dist/replay/normalizers/claude.d.ts +1 -1
- package/dist/replay/normalizers/openclaw.d.ts +1 -1
- package/dist/replay/normalizers/shared.d.ts +1 -1
- package/dist/replay/runner.d.ts +1 -1
- package/dist/replay/types.d.ts +1 -1
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/retrieval-agents.d.ts +2 -2
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/schemas.d.ts +74 -74
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/index.d.ts +1 -1
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-btIVRC4l.d.ts → semantic-consolidation-Dia_6FOn.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +9 -9
- package/dist/signal.d.ts +1 -1
- package/dist/{storage-BVv_Mqa0.d.ts → storage-RshM7i8-.d.ts} +18 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +2 -2
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +2 -2
- package/dist/temporal-timeline-recall.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +2 -2
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/types.d.ts +66 -66
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-Cmkz7csa.d.ts → types-CHSXOq7s.d.ts} +34 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +3 -3
- package/package.json +2 -2
- package/src/access-http.ts +40 -2
- package/src/config.test.ts +44 -0
- package/src/config.ts +43 -0
- package/src/namespaces/catalog.test.ts +258 -0
- package/src/namespaces/catalog.ts +235 -10
- package/src/namespaces/storage.ts +9 -0
- package/src/orchestrator.ts +3 -0
- package/src/relay/mission-access.test.ts +58 -3
- package/src/relay/mission-access.ts +17 -3
- package/src/relay/mission-ui.test.ts +90 -0
- package/src/storage-catalog-touch-gating.test.ts +70 -0
- package/src/storage.ts +31 -5
- package/src/types.ts +34 -0
- package/dist/chunk-3ZVFLMFQ.js.map +0 -1
- package/dist/chunk-5U65RIVI.js.map +0 -1
- package/dist/chunk-7LMEY5KZ.js.map +0 -1
- package/dist/chunk-7XRUYC6G.js.map +0 -1
- package/dist/chunk-JZM5QUTE.js.map +0 -1
- package/dist/chunk-KXLRMM35.js.map +0 -1
- package/dist/chunk-QHT4W3RU.js.map +0 -1
- /package/dist/{chunk-L4H6MQ7I.js.map → chunk-2RSL6YRA.js.map} +0 -0
- /package/dist/{chunk-JYRFO4EH.js.map → chunk-2ZPQUTMW.js.map} +0 -0
- /package/dist/{chunk-673NPXE3.js.map → chunk-3SIC3E65.js.map} +0 -0
- /package/dist/{chunk-OVMS3DCB.js.map → chunk-675YU5IK.js.map} +0 -0
- /package/dist/{chunk-TGBJ2OMK.js.map → chunk-BQTSGK5Y.js.map} +0 -0
- /package/dist/{chunk-6LM6PPDX.js.map → chunk-CE3GMHT4.js.map} +0 -0
- /package/dist/{chunk-YXOAQAH2.js.map → chunk-CLFY35ED.js.map} +0 -0
- /package/dist/{chunk-HQ7QZJM5.js.map → chunk-D4TLWDYR.js.map} +0 -0
- /package/dist/{chunk-LQKITFGB.js.map → chunk-FUUF7FMX.js.map} +0 -0
- /package/dist/{chunk-MXRMNKSY.js.map → chunk-I3GKTDM2.js.map} +0 -0
- /package/dist/{chunk-EQF5763Z.js.map → chunk-IQULRIZM.js.map} +0 -0
- /package/dist/{chunk-W6R43NLA.js.map → chunk-KQMKF4G2.js.map} +0 -0
- /package/dist/{chunk-DJCKXI4M.js.map → chunk-NVXQTRBL.js.map} +0 -0
- /package/dist/{chunk-GHVJASQ4.js.map → chunk-OCY3CRPG.js.map} +0 -0
- /package/dist/{chunk-YLTG6BO4.js.map → chunk-OIFZII6J.js.map} +0 -0
- /package/dist/{chunk-XF4KOVDK.js.map → chunk-PA6DMYEK.js.map} +0 -0
- /package/dist/{chunk-3OQ4IZJN.js.map → chunk-QPZCAVDC.js.map} +0 -0
- /package/dist/{chunk-DLWYAN6A.js.map → chunk-T6U7HDIY.js.map} +0 -0
- /package/dist/{chunk-UTDA43TB.js.map → chunk-TWGGNUWJ.js.map} +0 -0
- /package/dist/{chunk-SBFDL2EQ.js.map → chunk-UTZW3X2R.js.map} +0 -0
- /package/dist/{chunk-SECMZACR.js.map → chunk-XUQUBZ7O.js.map} +0 -0
- /package/dist/{chunk-WUDBMLC6.js.map → chunk-ZC3HG22I.js.map} +0 -0
- /package/dist/{chunk-VCEH5UYS.js.map → chunk-ZRXSHGXT.js.map} +0 -0
package/src/config.test.ts
CHANGED
|
@@ -2161,3 +2161,47 @@ test("parseConfig rejects non-positive extraction retry/breaker numerics and fal
|
|
|
2161
2161
|
assert.equal(c.extractionBreakerAuthCooldownMs, 1_800_000, "non-positive auth cooldown -> default");
|
|
2162
2162
|
assert.deepEqual(c.extractionRetryScheduleMs, [60_000, 300_000, 1_800_000, 7_200_000], "schedule with non-positive entry -> default");
|
|
2163
2163
|
});
|
|
2164
|
+
|
|
2165
|
+
test("parseConfig namespace-catalog touch-path knobs: defaults, 0-accepting numerics, and validation (#1903)", () => {
|
|
2166
|
+
// Documented defaults when absent.
|
|
2167
|
+
const d = parseConfig({});
|
|
2168
|
+
assert.equal(d.namespacesCatalogCompactBytes, 16 * 1024 * 1024, "compactBytes default 16 MB");
|
|
2169
|
+
assert.equal(d.namespacesCatalogReadTouchCoalesceMs, 60_000, "read coalesce default 60000ms");
|
|
2170
|
+
assert.equal(d.namespacesCatalogWriteTouchCoalesceMs, 1_000, "write coalesce default 1000ms");
|
|
2171
|
+
assert.equal(d.namespacesCatalogTouchStateWrites, false, "state-write touch default false");
|
|
2172
|
+
|
|
2173
|
+
// 0 is an accepted disable switch for every numeric (resolveNonNegativeIntegerConfig).
|
|
2174
|
+
assert.equal(parseConfig({ namespacesCatalogCompactBytes: 0 }).namespacesCatalogCompactBytes, 0);
|
|
2175
|
+
assert.equal(parseConfig({ namespacesCatalogReadTouchCoalesceMs: 0 }).namespacesCatalogReadTouchCoalesceMs, 0);
|
|
2176
|
+
assert.equal(parseConfig({ namespacesCatalogWriteTouchCoalesceMs: 0 }).namespacesCatalogWriteTouchCoalesceMs, 0);
|
|
2177
|
+
|
|
2178
|
+
// Positive integers pass through; CLI/env numeric strings coerce.
|
|
2179
|
+
assert.equal(parseConfig({ namespacesCatalogCompactBytes: 4096 }).namespacesCatalogCompactBytes, 4096);
|
|
2180
|
+
assert.equal(parseConfig({ namespacesCatalogReadTouchCoalesceMs: "30000" }).namespacesCatalogReadTouchCoalesceMs, 30_000);
|
|
2181
|
+
|
|
2182
|
+
// Boolean-like coercion for the state-write toggle (gotcha #36).
|
|
2183
|
+
assert.equal(parseConfig({ namespacesCatalogTouchStateWrites: true }).namespacesCatalogTouchStateWrites, true);
|
|
2184
|
+
assert.equal(parseConfig({ namespacesCatalogTouchStateWrites: "true" }).namespacesCatalogTouchStateWrites, true);
|
|
2185
|
+
assert.equal(parseConfig({ namespacesCatalogTouchStateWrites: "off" }).namespacesCatalogTouchStateWrites, false);
|
|
2186
|
+
|
|
2187
|
+
// Negative and non-integer numerics are rejected with the documented message.
|
|
2188
|
+
for (const key of [
|
|
2189
|
+
"namespacesCatalogCompactBytes",
|
|
2190
|
+
"namespacesCatalogReadTouchCoalesceMs",
|
|
2191
|
+
"namespacesCatalogWriteTouchCoalesceMs",
|
|
2192
|
+
]) {
|
|
2193
|
+
for (const bad of [-1, 1.5, "1.5", "abc", Number.NaN, Infinity]) {
|
|
2194
|
+
assert.throws(
|
|
2195
|
+
() => parseConfig({ [key]: bad }),
|
|
2196
|
+
new RegExp(`${key} must be a non-negative integer`),
|
|
2197
|
+
`${key}=${JSON.stringify(bad)} should throw`,
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// A present-but-malformed boolean toggle fails fast (gotcha #51).
|
|
2203
|
+
assert.throws(
|
|
2204
|
+
() => parseConfig({ namespacesCatalogTouchStateWrites: "maybe" }),
|
|
2205
|
+
/namespacesCatalogTouchStateWrites must be a boolean-like value/,
|
|
2206
|
+
);
|
|
2207
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -435,6 +435,26 @@ function resolvePositiveIntegerConfig(
|
|
|
435
435
|
return coerced;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
function resolveNonNegativeIntegerConfig(
|
|
439
|
+
value: unknown,
|
|
440
|
+
defaultValue: number,
|
|
441
|
+
keyName: string,
|
|
442
|
+
): number {
|
|
443
|
+
if (value === undefined || value === null) return defaultValue;
|
|
444
|
+
const coerced = coerceNumber(value);
|
|
445
|
+
if (
|
|
446
|
+
coerced === undefined ||
|
|
447
|
+
!Number.isFinite(coerced) ||
|
|
448
|
+
!Number.isInteger(coerced) ||
|
|
449
|
+
coerced < 0
|
|
450
|
+
) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
`${keyName} must be a non-negative integer; got ${JSON.stringify(value)}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return coerced;
|
|
456
|
+
}
|
|
457
|
+
|
|
438
458
|
export function isOpenaiApiKeyDisabled(value: unknown): boolean {
|
|
439
459
|
return value === false || (typeof value === "string" && value.trim().toLowerCase() === "false");
|
|
440
460
|
}
|
|
@@ -2934,6 +2954,29 @@ export function parseConfig(
|
|
|
2934
2954
|
// silently defaulting to enabled (CLAUDE.md rule #51); default to enabled
|
|
2935
2955
|
// only when the value is absent.
|
|
2936
2956
|
namespaceCatalogEnabled: resolveNamespaceCatalogEnabled(cfg.namespaceCatalogEnabled, rawOperatorConfig, runtimeSet),
|
|
2957
|
+
// Namespace catalog touch-path performance knobs (issue #1903). Numerics use
|
|
2958
|
+
// resolveNonNegativeIntegerConfig so 0 is an accepted disable switch; a
|
|
2959
|
+
// present-but-invalid value (negative/non-integer) is REJECTED (rule #51).
|
|
2960
|
+
namespacesCatalogCompactBytes: resolveNonNegativeIntegerConfig(
|
|
2961
|
+
cfg.namespacesCatalogCompactBytes,
|
|
2962
|
+
16 * 1024 * 1024,
|
|
2963
|
+
"namespacesCatalogCompactBytes",
|
|
2964
|
+
),
|
|
2965
|
+
namespacesCatalogReadTouchCoalesceMs: resolveNonNegativeIntegerConfig(
|
|
2966
|
+
cfg.namespacesCatalogReadTouchCoalesceMs,
|
|
2967
|
+
60_000,
|
|
2968
|
+
"namespacesCatalogReadTouchCoalesceMs",
|
|
2969
|
+
),
|
|
2970
|
+
namespacesCatalogWriteTouchCoalesceMs: resolveNonNegativeIntegerConfig(
|
|
2971
|
+
cfg.namespacesCatalogWriteTouchCoalesceMs,
|
|
2972
|
+
1_000,
|
|
2973
|
+
"namespacesCatalogWriteTouchCoalesceMs",
|
|
2974
|
+
),
|
|
2975
|
+
namespacesCatalogTouchStateWrites: resolveBooleanConfig(
|
|
2976
|
+
cfg.namespacesCatalogTouchStateWrites,
|
|
2977
|
+
false,
|
|
2978
|
+
"namespacesCatalogTouchStateWrites",
|
|
2979
|
+
),
|
|
2937
2980
|
// NOTE: namespace identifiers are intentionally NOT sanitized here — the
|
|
2938
2981
|
// codebase rejects unsafe namespaces at the point of use (see
|
|
2939
2982
|
// codex-materialize-runner and NamespaceStorageRouter / resolveNamespaceDir),
|
|
@@ -3694,3 +3694,261 @@ test("router resolve-hook serializer recovers after a prior hook rejects (issue
|
|
|
3694
3694
|
await rm(memoryDir, { recursive: true, force: true });
|
|
3695
3695
|
}
|
|
3696
3696
|
});
|
|
3697
|
+
|
|
3698
|
+
// ── Issue #1903: touch-path performance (auto-compaction, coalescing, streaming) ──
|
|
3699
|
+
|
|
3700
|
+
async function countCatalogLines(memoryDir: string): Promise<number> {
|
|
3701
|
+
const raw = await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8").catch(() => "");
|
|
3702
|
+
return raw.split("\n").filter((line) => line.trim().length > 0).length;
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
// 1. Warm cache: with coalescing OFF every touch appends, but a warm cache means
|
|
3706
|
+
// none of them re-parses the JSONL after the initial warm-up parse.
|
|
3707
|
+
test("#1903 warm cache: repeated touches never re-parse the log (coalescing off)", async () => {
|
|
3708
|
+
const memoryDir = await mkMemoryDir();
|
|
3709
|
+
try {
|
|
3710
|
+
const catalog = new CountingNamespaceCatalog(
|
|
3711
|
+
makeConfig(memoryDir, {
|
|
3712
|
+
namespacesCatalogReadTouchCoalesceMs: 0,
|
|
3713
|
+
namespacesCatalogWriteTouchCoalesceMs: 0,
|
|
3714
|
+
}),
|
|
3715
|
+
);
|
|
3716
|
+
// Warm the cache: the first write creates the file; a read then performs the
|
|
3717
|
+
// one-time full parse and warms the in-process cache.
|
|
3718
|
+
await catalog.markWrite("alpha", { discoveredBy: "write" });
|
|
3719
|
+
await catalog.listNamespaces();
|
|
3720
|
+
const warm = catalog.catalogReadCount;
|
|
3721
|
+
assert.ok(warm >= 1, "the warm-up performed at least one full parse");
|
|
3722
|
+
for (let i = 0; i < 25; i++) {
|
|
3723
|
+
await catalog.markWrite("alpha", { discoveredBy: "write" });
|
|
3724
|
+
await catalog.markRead("alpha", { discoveredBy: "read" });
|
|
3725
|
+
}
|
|
3726
|
+
assert.equal(
|
|
3727
|
+
catalog.catalogReadCount,
|
|
3728
|
+
warm,
|
|
3729
|
+
"every subsequent touch is served from the warm cache without a re-parse",
|
|
3730
|
+
);
|
|
3731
|
+
} finally {
|
|
3732
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3733
|
+
}
|
|
3734
|
+
});
|
|
3735
|
+
|
|
3736
|
+
// 2. Write-touch coalescing collapses many in-window writes into a single append,
|
|
3737
|
+
// and the newest buffered timestamp wins on flush.
|
|
3738
|
+
test("#1903 write-touch coalescing collapses 50 in-window writes to one append", async () => {
|
|
3739
|
+
const memoryDir = await mkMemoryDir();
|
|
3740
|
+
try {
|
|
3741
|
+
const catalog = new NamespaceCatalog(
|
|
3742
|
+
makeConfig(memoryDir, { namespacesCatalogWriteTouchCoalesceMs: 60_000 }),
|
|
3743
|
+
);
|
|
3744
|
+
// Seed the record, then read it back so the in-process cache is warm — the
|
|
3745
|
+
// following writes are then pure-timestamp refreshes on a known record and
|
|
3746
|
+
// are deferred (a cold cache would force the first one to flush immediately).
|
|
3747
|
+
await catalog.markWrite("alpha", { discoveredBy: "write" });
|
|
3748
|
+
await catalog.getNamespaceRecord("alpha");
|
|
3749
|
+
const linesAfterSeed = await countCatalogLines(memoryDir);
|
|
3750
|
+
let newest = new Date();
|
|
3751
|
+
for (let i = 0; i < 50; i++) {
|
|
3752
|
+
newest = new Date(Date.now() + (i + 1) * 1000);
|
|
3753
|
+
await catalog.markWrite("alpha", { discoveredBy: "write", at: newest });
|
|
3754
|
+
}
|
|
3755
|
+
assert.equal(
|
|
3756
|
+
await countCatalogLines(memoryDir),
|
|
3757
|
+
linesAfterSeed,
|
|
3758
|
+
"coalesced writes stay buffered — no per-touch append",
|
|
3759
|
+
);
|
|
3760
|
+
await catalog.flushPendingTouches();
|
|
3761
|
+
assert.equal(
|
|
3762
|
+
await countCatalogLines(memoryDir),
|
|
3763
|
+
linesAfterSeed + 1,
|
|
3764
|
+
"the 50 buffered writes collapse into exactly one append on flush",
|
|
3765
|
+
);
|
|
3766
|
+
const record = await catalog.getNamespaceRecord("alpha");
|
|
3767
|
+
assert.equal(
|
|
3768
|
+
record?.lastWriteAt,
|
|
3769
|
+
newest.toISOString(),
|
|
3770
|
+
"the newest in-window write timestamp wins",
|
|
3771
|
+
);
|
|
3772
|
+
} finally {
|
|
3773
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3774
|
+
}
|
|
3775
|
+
});
|
|
3776
|
+
|
|
3777
|
+
// 3. Read-touch coalescing collapses in-window reads and keeps the latest freshness.
|
|
3778
|
+
test("#1903 read-touch coalescing keeps the latest in-window lastReadAt", async () => {
|
|
3779
|
+
const memoryDir = await mkMemoryDir();
|
|
3780
|
+
try {
|
|
3781
|
+
const catalog = new NamespaceCatalog(
|
|
3782
|
+
makeConfig(memoryDir, { namespacesCatalogReadTouchCoalesceMs: 60_000 }),
|
|
3783
|
+
);
|
|
3784
|
+
// Seed the read field, then warm the cache so the following reads defer.
|
|
3785
|
+
await catalog.markRead("alpha", { discoveredBy: "read" });
|
|
3786
|
+
await catalog.getNamespaceRecord("alpha");
|
|
3787
|
+
const linesAfterSeed = await countCatalogLines(memoryDir);
|
|
3788
|
+
let newest = new Date();
|
|
3789
|
+
for (let i = 0; i < 30; i++) {
|
|
3790
|
+
newest = new Date(Date.now() + (i + 1) * 1000);
|
|
3791
|
+
await catalog.markRead("alpha", { discoveredBy: "read", at: newest });
|
|
3792
|
+
}
|
|
3793
|
+
assert.equal(
|
|
3794
|
+
await countCatalogLines(memoryDir),
|
|
3795
|
+
linesAfterSeed,
|
|
3796
|
+
"coalesced reads stay buffered — no per-touch append",
|
|
3797
|
+
);
|
|
3798
|
+
await catalog.flushPendingTouches();
|
|
3799
|
+
assert.equal(await countCatalogLines(memoryDir), linesAfterSeed + 1);
|
|
3800
|
+
const record = await catalog.getNamespaceRecord("alpha");
|
|
3801
|
+
assert.equal(
|
|
3802
|
+
record?.lastReadAt,
|
|
3803
|
+
newest.toISOString(),
|
|
3804
|
+
"the newest in-window read timestamp wins",
|
|
3805
|
+
);
|
|
3806
|
+
} finally {
|
|
3807
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3808
|
+
}
|
|
3809
|
+
});
|
|
3810
|
+
|
|
3811
|
+
// 4. Provenance upgrade under coalescing: a config pre-registration followed by a
|
|
3812
|
+
// write must read back "write" WITHOUT an explicit flush (immediate-flush rule).
|
|
3813
|
+
test("#1903 markWrite upgrades config→write under coalescing without an explicit flush", async () => {
|
|
3814
|
+
const memoryDir = await mkMemoryDir();
|
|
3815
|
+
try {
|
|
3816
|
+
const catalog = new NamespaceCatalog(
|
|
3817
|
+
makeConfig(memoryDir, {
|
|
3818
|
+
namespacesCatalogReadTouchCoalesceMs: 60_000,
|
|
3819
|
+
namespacesCatalogWriteTouchCoalesceMs: 60_000,
|
|
3820
|
+
}),
|
|
3821
|
+
);
|
|
3822
|
+
const ns = "project-origin-prov";
|
|
3823
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
3824
|
+
await catalog.registerResolved(ns, storageDir);
|
|
3825
|
+
assert.equal(
|
|
3826
|
+
(await catalog.getNamespaceRecord(ns))?.discoveredBy,
|
|
3827
|
+
"config",
|
|
3828
|
+
"pre-registration is discovered by config",
|
|
3829
|
+
);
|
|
3830
|
+
// A real write must upgrade the provenance immediately even with coalescing on.
|
|
3831
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir });
|
|
3832
|
+
assert.equal(
|
|
3833
|
+
(await catalog.getNamespaceRecord(ns))?.discoveredBy,
|
|
3834
|
+
"write",
|
|
3835
|
+
"the config→write upgrade flushes immediately (not coalesced away)",
|
|
3836
|
+
);
|
|
3837
|
+
} finally {
|
|
3838
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
|
|
3842
|
+
// 5. Zero-limit semantics: CompactBytes=0 never rewrites and CoalesceMs=0 appends
|
|
3843
|
+
// one line per touch (line count == touch count) — the pre-#1903 behavior.
|
|
3844
|
+
test("#1903 zero knobs restore append-per-touch with no compaction", async () => {
|
|
3845
|
+
const memoryDir = await mkMemoryDir();
|
|
3846
|
+
try {
|
|
3847
|
+
const catalog = new NamespaceCatalog(
|
|
3848
|
+
makeConfig(memoryDir, {
|
|
3849
|
+
namespacesCatalogCompactBytes: 0,
|
|
3850
|
+
namespacesCatalogReadTouchCoalesceMs: 0,
|
|
3851
|
+
namespacesCatalogWriteTouchCoalesceMs: 0,
|
|
3852
|
+
}),
|
|
3853
|
+
);
|
|
3854
|
+
const touches = 12;
|
|
3855
|
+
for (let i = 0; i < touches; i++) {
|
|
3856
|
+
await catalog.markWrite("alpha", { discoveredBy: "write" });
|
|
3857
|
+
}
|
|
3858
|
+
assert.equal(
|
|
3859
|
+
await countCatalogLines(memoryDir),
|
|
3860
|
+
touches,
|
|
3861
|
+
"no coalescing and no compaction → exactly one append per touch",
|
|
3862
|
+
);
|
|
3863
|
+
} finally {
|
|
3864
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3865
|
+
}
|
|
3866
|
+
});
|
|
3867
|
+
|
|
3868
|
+
// 6. Auto-compaction bounds the log to one row per namespace and preserves every
|
|
3869
|
+
// namespace with its newest fields. A tiny limit forces compaction on each touch.
|
|
3870
|
+
test("#1903 auto-compaction bounds the log to one row per namespace and preserves all", async () => {
|
|
3871
|
+
const memoryDir = await mkMemoryDir();
|
|
3872
|
+
try {
|
|
3873
|
+
const catalog = new NamespaceCatalog(
|
|
3874
|
+
makeConfig(memoryDir, {
|
|
3875
|
+
namespacesCatalogCompactBytes: 100,
|
|
3876
|
+
namespacesCatalogReadTouchCoalesceMs: 0,
|
|
3877
|
+
namespacesCatalogWriteTouchCoalesceMs: 0,
|
|
3878
|
+
}),
|
|
3879
|
+
);
|
|
3880
|
+
const namespaces = [
|
|
3881
|
+
"project-origin-a",
|
|
3882
|
+
"project-origin-b",
|
|
3883
|
+
"project-origin-c",
|
|
3884
|
+
"project-origin-d",
|
|
3885
|
+
"project-origin-e",
|
|
3886
|
+
];
|
|
3887
|
+
const newest = new Map<string, string>();
|
|
3888
|
+
for (let round = 0; round < 20; round++) {
|
|
3889
|
+
for (const ns of namespaces) {
|
|
3890
|
+
const at = new Date(Date.now() + (round * namespaces.length + namespaces.indexOf(ns)) * 1000);
|
|
3891
|
+
newest.set(ns, at.toISOString());
|
|
3892
|
+
await catalog.markWrite(ns, { discoveredBy: "write", at });
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
assert.equal(
|
|
3896
|
+
await countCatalogLines(memoryDir),
|
|
3897
|
+
namespaces.length,
|
|
3898
|
+
"the log is bounded to exactly one row per namespace after compaction",
|
|
3899
|
+
);
|
|
3900
|
+
const list = await catalog.listNamespaces();
|
|
3901
|
+
for (const ns of namespaces) {
|
|
3902
|
+
const record = list.find((r) => r.namespace === ns);
|
|
3903
|
+
assert.ok(record, `namespace ${ns} survives compaction`);
|
|
3904
|
+
assert.equal(
|
|
3905
|
+
record?.lastWriteAt,
|
|
3906
|
+
newest.get(ns),
|
|
3907
|
+
`namespace ${ns} keeps its newest write timestamp through compaction`,
|
|
3908
|
+
);
|
|
3909
|
+
}
|
|
3910
|
+
} finally {
|
|
3911
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3912
|
+
}
|
|
3913
|
+
});
|
|
3914
|
+
|
|
3915
|
+
// 7. Cross-process cache-coherence: compaction on one instance must never drop
|
|
3916
|
+
// another instance's rows (mirrors the rebuild-race tests). A third fresh
|
|
3917
|
+
// instance must read back every namespace touched by either.
|
|
3918
|
+
test("#1903 cross-instance compaction never drops another instance's rows", async () => {
|
|
3919
|
+
const memoryDir = await mkMemoryDir();
|
|
3920
|
+
try {
|
|
3921
|
+
const cfg = () =>
|
|
3922
|
+
makeConfig(memoryDir, {
|
|
3923
|
+
namespacesCatalogCompactBytes: 100,
|
|
3924
|
+
namespacesCatalogReadTouchCoalesceMs: 0,
|
|
3925
|
+
namespacesCatalogWriteTouchCoalesceMs: 0,
|
|
3926
|
+
});
|
|
3927
|
+
// Separate instances behave like separate processes (own lock owner id).
|
|
3928
|
+
const a = new NamespaceCatalog(cfg());
|
|
3929
|
+
const b = new NamespaceCatalog(cfg());
|
|
3930
|
+
const written = new Set<string>();
|
|
3931
|
+
for (let round = 0; round < 8; round++) {
|
|
3932
|
+
const nsA = `project-origin-a${round}`;
|
|
3933
|
+
const nsB = `project-origin-b${round}`;
|
|
3934
|
+
written.add(nsA);
|
|
3935
|
+
written.add(nsB);
|
|
3936
|
+
// Interleave: A appends a fresh namespace while B triggers a compaction of
|
|
3937
|
+
// the shared log. The cross-process lock serializes the two.
|
|
3938
|
+
await Promise.all([
|
|
3939
|
+
a.markWrite(nsA, { discoveredBy: "write" }),
|
|
3940
|
+
b.markWrite(nsB, { discoveredBy: "write" }),
|
|
3941
|
+
]);
|
|
3942
|
+
}
|
|
3943
|
+
const fresh = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3944
|
+
const list = await fresh.listNamespaces();
|
|
3945
|
+
for (const ns of written) {
|
|
3946
|
+
assert.ok(
|
|
3947
|
+
list.some((r) => r.namespace === ns),
|
|
3948
|
+
`namespace ${ns} must survive cross-instance interleaved compaction`,
|
|
3949
|
+
);
|
|
3950
|
+
}
|
|
3951
|
+
} finally {
|
|
3952
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3953
|
+
}
|
|
3954
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
stat,
|
|
13
13
|
writeFile,
|
|
14
14
|
} from "node:fs/promises";
|
|
15
|
+
import { StringDecoder } from "node:string_decoder";
|
|
15
16
|
import type { PluginConfig } from "../types.js";
|
|
16
17
|
import {
|
|
17
18
|
MutationSerializer,
|
|
@@ -497,12 +498,46 @@ export class NamespaceCatalog {
|
|
|
497
498
|
// against this normalized form everywhere instead.
|
|
498
499
|
private readonly defaultNamespaceIdentity: string;
|
|
499
500
|
|
|
501
|
+
// Issue #1903 — touch-path performance knobs, resolved with the documented
|
|
502
|
+
// defaults when an externally-built PluginConfig omits them (so a partial
|
|
503
|
+
// config object still yields the production behavior). `0` is a real disable
|
|
504
|
+
// switch for each and is preserved by `??` (0 is not null/undefined).
|
|
505
|
+
private readonly compactBytesLimit: number;
|
|
506
|
+
private readonly readCoalesceMs: number;
|
|
507
|
+
private readonly writeCoalesceMs: number;
|
|
508
|
+
// Size after the most recent auto-compaction (0 = none yet). Hysteresis for
|
|
509
|
+
// maybeAutoCompact: a folded catalog that is itself above the limit (many
|
|
510
|
+
// distinct namespaces) would otherwise be re-folded + re-rewritten on every
|
|
511
|
+
// touch. We only compact again once the log has grown by >= one limit-worth
|
|
512
|
+
// since the last compaction (#1903, Codex).
|
|
513
|
+
private lastCompactedSize = 0;
|
|
514
|
+
|
|
515
|
+
// Issue #1903 — per-(namespace, kind) coalescing buffer. Keyed `${kind}:${ns}`.
|
|
516
|
+
// Holds the latest buffered timestamp/metadata for a pure-timestamp touch on
|
|
517
|
+
// an already-known record; a `.unref()`'d timer flushes it once per window so
|
|
518
|
+
// the append-only log does not grow one line per repeat touch. Semantically
|
|
519
|
+
// observable touches (first sight, provenance/field change) bypass this buffer
|
|
520
|
+
// and flush immediately (see `coalesceTouch`).
|
|
521
|
+
private readonly pendingTouches = new Map<
|
|
522
|
+
string,
|
|
523
|
+
{
|
|
524
|
+
namespace: string;
|
|
525
|
+
kind: "read" | "write";
|
|
526
|
+
metadata?: NamespaceTouchMetadata;
|
|
527
|
+
at: Date;
|
|
528
|
+
timer: NodeJS.Timeout;
|
|
529
|
+
}
|
|
530
|
+
>();
|
|
531
|
+
|
|
500
532
|
constructor(private readonly config: PluginConfig) {
|
|
501
533
|
this.memoryDir = config.memoryDir;
|
|
502
534
|
this.stateDir = path.join(this.memoryDir, STATE_DIR);
|
|
503
535
|
this.catalogPath = path.join(this.stateDir, CATALOG_FILE);
|
|
504
536
|
this.rebuildLockPath = path.join(this.stateDir, REBUILD_LOCK_FILE);
|
|
505
537
|
this.defaultNamespaceIdentity = normalizeNamespaceIdentity(config.defaultNamespace);
|
|
538
|
+
this.compactBytesLimit = config.namespacesCatalogCompactBytes ?? 16 * 1024 * 1024;
|
|
539
|
+
this.readCoalesceMs = config.namespacesCatalogReadTouchCoalesceMs ?? 60_000;
|
|
540
|
+
this.writeCoalesceMs = config.namespacesCatalogWriteTouchCoalesceMs ?? 1_000;
|
|
506
541
|
}
|
|
507
542
|
|
|
508
543
|
/** Whether the catalog is active (namespaces enabled and catalog not opted out). */
|
|
@@ -663,11 +698,137 @@ export class NamespaceCatalog {
|
|
|
663
698
|
// ── Touch API (cheap, failure-tolerant) ─────────────────────────────────
|
|
664
699
|
|
|
665
700
|
async markRead(namespace: string, metadata?: NamespaceTouchMetadata): Promise<void> {
|
|
666
|
-
await this.
|
|
701
|
+
await this.coalesceTouch(namespace, "read", metadata);
|
|
667
702
|
}
|
|
668
703
|
|
|
669
704
|
async markWrite(namespace: string, metadata?: NamespaceTouchMetadata): Promise<void> {
|
|
670
|
-
await this.
|
|
705
|
+
await this.coalesceTouch(namespace, "write", metadata);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Route a read/write touch through the per-(namespace, kind) coalescing buffer
|
|
710
|
+
* (issue #1903). A touch flushes IMMEDIATELY (bypassing the buffer) whenever it
|
|
711
|
+
* is semantically observable to an in-process reader — first sight of the
|
|
712
|
+
* namespace, a `config`→`write` provenance upgrade, the first time a touch
|
|
713
|
+
* field is set (so `listNamespaces({ writtenSince })` and record presence stay
|
|
714
|
+
* correct), or a change to `kind`/`principal`/`projectId`/`branch`/
|
|
715
|
+
* `parentNamespace`. A pure timestamp refresh on an already-known record is
|
|
716
|
+
* DEFERRED and coalesced: the newest buffered `at`/metadata wins and a single
|
|
717
|
+
* `touch` runs when the window elapses (or on `flushPendingTouches`). Window
|
|
718
|
+
* `0` skips the buffer entirely (the pre-#1903 immediate-append behavior).
|
|
719
|
+
* Best-effort: coalescing decisions read only the warm `compactedCache`, never
|
|
720
|
+
* forcing a disk parse; a cold cache flushes immediately (and re-warms it).
|
|
721
|
+
*/
|
|
722
|
+
private async coalesceTouch(
|
|
723
|
+
namespace: string,
|
|
724
|
+
kind: "read" | "write",
|
|
725
|
+
metadata?: NamespaceTouchMetadata,
|
|
726
|
+
): Promise<void> {
|
|
727
|
+
if (!this.enabled) return;
|
|
728
|
+
const window = kind === "read" ? this.readCoalesceMs : this.writeCoalesceMs;
|
|
729
|
+
if (window <= 0) {
|
|
730
|
+
await this.touch(namespace, kind, metadata);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// Validate up front so an unsafe namespace rejects deterministically, exactly
|
|
734
|
+
// as the immediate-append path does (touch validates too).
|
|
735
|
+
const ns = this.validateNamespace(namespace);
|
|
736
|
+
const cached = this.compactedCache?.records.get(ns);
|
|
737
|
+
if (cached === undefined || this.touchMustFlushImmediately(kind, metadata, cached)) {
|
|
738
|
+
// Semantically observable in-process → flush now (also warms the cache).
|
|
739
|
+
// Any older buffered touch for this key is now redundant (this newer touch
|
|
740
|
+
// updates the same field with a newer timestamp): drop it so its stale
|
|
741
|
+
// timer cannot regress the timestamp after this flush.
|
|
742
|
+
const stale = this.pendingTouches.get(`${kind}:${ns}`);
|
|
743
|
+
if (stale) {
|
|
744
|
+
clearTimeout(stale.timer);
|
|
745
|
+
this.pendingTouches.delete(`${kind}:${ns}`);
|
|
746
|
+
}
|
|
747
|
+
await this.touch(namespace, kind, metadata);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
// Pure timestamp refresh on a known record → coalesce within a FIXED window.
|
|
751
|
+
const key = `${kind}:${ns}`;
|
|
752
|
+
const at = metadata?.at ?? new Date();
|
|
753
|
+
const existing = this.pendingTouches.get(key);
|
|
754
|
+
if (existing) {
|
|
755
|
+
// Keep the ORIGINAL timer and only refresh the buffered payload (#1903,
|
|
756
|
+
// Codex). Re-arming on every touch would make this a debounce that never
|
|
757
|
+
// flushes under continuous traffic (e.g. a sustained import), leaving
|
|
758
|
+
// lastWriteAt stale and writtenSince/maintenance consumers blind to an
|
|
759
|
+
// actively-written namespace. With a fixed window, each window flushes on
|
|
760
|
+
// schedule carrying the newest buffered timestamp.
|
|
761
|
+
existing.metadata = metadata;
|
|
762
|
+
existing.at = at;
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
// Cap at Node's maximum setTimeout delay (2^31-1 ms ≈ 24.8 days). Values
|
|
766
|
+
// above that are clamped by Node to 1ms, which would append almost every
|
|
767
|
+
// touch and recreate the churn this coalescing exists to prevent (#1903, Codex).
|
|
768
|
+
const safeWindow = Math.min(window, 2_147_483_647);
|
|
769
|
+
const timer = setTimeout(() => {
|
|
770
|
+
void this.flushPendingTouch(key);
|
|
771
|
+
}, safeWindow);
|
|
772
|
+
timer.unref();
|
|
773
|
+
this.pendingTouches.set(key, { namespace: ns, kind, metadata, at, timer });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Whether a read/write touch on an EXISTING cached record must flush
|
|
778
|
+
* immediately rather than coalesce. Only pure timestamp refreshes (the value
|
|
779
|
+
* returns false) are safe to defer.
|
|
780
|
+
*/
|
|
781
|
+
private touchMustFlushImmediately(
|
|
782
|
+
kind: "read" | "write",
|
|
783
|
+
metadata: NamespaceTouchMetadata | undefined,
|
|
784
|
+
cached: NamespaceRecord,
|
|
785
|
+
): boolean {
|
|
786
|
+
// A write upgrading a config pre-registration to "write" changes the
|
|
787
|
+
// discoveredBy filter result — must be observable at once (~:1261).
|
|
788
|
+
if (kind === "write" && cached.discoveredBy === "config") return true;
|
|
789
|
+
// The first time a touch field is set makes the namespace newly match
|
|
790
|
+
// presence/`writtenSince` reads, so it must not be deferred.
|
|
791
|
+
if (kind === "write" && cached.lastWriteAt === undefined) return true;
|
|
792
|
+
if (kind === "read" && cached.lastReadAt === undefined) return true;
|
|
793
|
+
if (!metadata) return false;
|
|
794
|
+
if (metadata.kind !== undefined && metadata.kind !== cached.kind) return true;
|
|
795
|
+
if (metadata.principal !== undefined && metadata.principal !== cached.principal) return true;
|
|
796
|
+
if (metadata.projectId !== undefined && metadata.projectId !== cached.projectId) return true;
|
|
797
|
+
if (metadata.branch !== undefined && metadata.branch !== cached.branch) return true;
|
|
798
|
+
if (metadata.parentNamespace !== undefined && metadata.parentNamespace !== cached.parentNamespace) {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
// A changed storageDir repoints routing/containment for this namespace —
|
|
802
|
+
// must be observable at once, not held stale by the coalesce window (#1903, Cursor).
|
|
803
|
+
if (metadata.storageDir !== undefined && metadata.storageDir !== cached.storageDir) return true;
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Fire a single buffered touch when its coalescing window elapses. */
|
|
808
|
+
private async flushPendingTouch(key: string): Promise<void> {
|
|
809
|
+
const pending = this.pendingTouches.get(key);
|
|
810
|
+
if (!pending) return;
|
|
811
|
+
this.pendingTouches.delete(key);
|
|
812
|
+
clearTimeout(pending.timer);
|
|
813
|
+
await this.touch(pending.namespace, pending.kind, { ...pending.metadata, at: pending.at }).catch(
|
|
814
|
+
() => undefined,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Flush every buffered coalesced touch now (issue #1903). Called from the
|
|
820
|
+
* router/orchestrator shutdown hook so a long-lived host does not drop
|
|
821
|
+
* buffered read/write timestamps on teardown, and by tests to make coalesced
|
|
822
|
+
* touches deterministically observable. Best-effort: a failed flush never
|
|
823
|
+
* throws (each touch is `.catch()`-guarded).
|
|
824
|
+
*/
|
|
825
|
+
async flushPendingTouches(): Promise<void> {
|
|
826
|
+
const pending = [...this.pendingTouches.values()];
|
|
827
|
+
this.pendingTouches.clear();
|
|
828
|
+
for (const p of pending) {
|
|
829
|
+
clearTimeout(p.timer);
|
|
830
|
+
await this.touch(p.namespace, p.kind, { ...p.metadata, at: p.at }).catch(() => undefined);
|
|
831
|
+
}
|
|
671
832
|
}
|
|
672
833
|
|
|
673
834
|
async markMaintenance(namespace: string, jobName: string, at?: Date): Promise<void> {
|
|
@@ -1269,11 +1430,48 @@ export class NamespaceCatalog {
|
|
|
1269
1430
|
}
|
|
1270
1431
|
|
|
1271
1432
|
await this.appendUnchained(record);
|
|
1433
|
+
// Size-triggered auto-compaction (issue #1903), still inside the held
|
|
1434
|
+
// cross-process lock + queueCritical turn so the fold→rename is atomic
|
|
1435
|
+
// against concurrent touches and cross-process rebuilds.
|
|
1436
|
+
await this.maybeAutoCompact();
|
|
1272
1437
|
return true;
|
|
1273
1438
|
}),
|
|
1274
1439
|
);
|
|
1275
1440
|
}
|
|
1276
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
|
|
1444
|
+
* (issue #1903). MUST be called only from inside `touch`'s held-lock +
|
|
1445
|
+
* `queueCritical` critical section — it reuses `loadCompacted` (last-record-
|
|
1446
|
+
* wins fold; every namespace row survives) and `rewriteUnchained` (atomic
|
|
1447
|
+
* temp-file + rename), so the collapse is cross-process safe and never
|
|
1448
|
+
* invokes the disk-scan purge in `finishRebuild`. Best-effort and fail-open
|
|
1449
|
+
* (rule #40): the append already succeeded and is durable, so a failed
|
|
1450
|
+
* compaction must not fail the touch — the next over-limit touch retries.
|
|
1451
|
+
*/
|
|
1452
|
+
private async maybeAutoCompact(): Promise<void> {
|
|
1453
|
+
try {
|
|
1454
|
+
const limit = this.compactBytesLimit; // 0 => auto-compaction disabled
|
|
1455
|
+
if (limit <= 0) return;
|
|
1456
|
+
// Exact post-append size when the cache is warm; skip this touch's check
|
|
1457
|
+
// when the cache is cold (a cross-process append invalidated it) — the
|
|
1458
|
+
// next touch re-warms and re-checks.
|
|
1459
|
+
const size = this.compactedCache?.identity.size;
|
|
1460
|
+
if (size === undefined || size <= limit) return;
|
|
1461
|
+
// Hysteresis (issue #1903, Codex): a folded catalog whose deduped state is
|
|
1462
|
+
// itself above the limit (many distinct namespaces) would otherwise be
|
|
1463
|
+
// re-folded + re-rewritten on EVERY touch — O(catalog) per touch. Only
|
|
1464
|
+
// compact again once the log has grown by >= one limit-worth since the last
|
|
1465
|
+
// compaction; by then the new appends are duplicates the fold can collapse.
|
|
1466
|
+
if (this.lastCompactedSize > 0 && size - this.lastCompactedSize < limit) return;
|
|
1467
|
+
const folded = await this.loadCompacted();
|
|
1468
|
+
await this.rewriteUnchained([...folded.values()]);
|
|
1469
|
+
this.lastCompactedSize = this.compactedCache?.identity.size ?? size;
|
|
1470
|
+
} catch {
|
|
1471
|
+
// Fail-open: compaction is best-effort maintenance on already-durable data.
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1277
1475
|
// ── Rebuild from disk ────────────────────────────────────────────────────
|
|
1278
1476
|
|
|
1279
1477
|
async rebuildFromDisk(
|
|
@@ -1957,25 +2155,52 @@ export class NamespaceCatalog {
|
|
|
1957
2155
|
|
|
1958
2156
|
try {
|
|
1959
2157
|
this.onCatalogReadForTest?.();
|
|
1960
|
-
|
|
1961
|
-
|
|
2158
|
+
// Streaming chunked parse (issue #1903): the log can reach 100+ MB in
|
|
2159
|
+
// production, so read it in bounded chunks and yield to the event loop
|
|
2160
|
+
// every ~5000 lines instead of buffering the whole file and blocking on a
|
|
2161
|
+
// single giant `split("\n")`. The fold is unchanged (last-record-wins with
|
|
2162
|
+
// field-level touch merge across cross-process full-snapshot appends).
|
|
2163
|
+
const CHUNK_BYTES = 1 << 16; // 64 KiB
|
|
2164
|
+
const YIELD_EVERY_LINES = 5000;
|
|
2165
|
+
const chunk = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
2166
|
+
// StringDecoder buffers partial multi-byte UTF-8 sequences that straddle a
|
|
2167
|
+
// chunk boundary, so a namespace/principal with non-ASCII bytes never
|
|
2168
|
+
// corrupts at the seam.
|
|
2169
|
+
const decoder = new StringDecoder("utf8");
|
|
2170
|
+
let leftover = "";
|
|
2171
|
+
let linesSinceYield = 0;
|
|
2172
|
+
const foldLine = (line: string): void => {
|
|
1962
2173
|
const trimmed = line.trim();
|
|
1963
|
-
if (trimmed.length === 0)
|
|
2174
|
+
if (trimmed.length === 0) return;
|
|
1964
2175
|
let parsed: unknown;
|
|
1965
2176
|
try {
|
|
1966
2177
|
parsed = JSON.parse(trimmed);
|
|
1967
2178
|
} catch {
|
|
1968
2179
|
// Skip corrupt lines (CLAUDE.md rule #18 robustness).
|
|
1969
|
-
|
|
2180
|
+
return;
|
|
1970
2181
|
}
|
|
1971
2182
|
const record = coerceRecord(parsed);
|
|
1972
|
-
if (!record)
|
|
1973
|
-
// Preserve each touch field across cross-process full-snapshot appends.
|
|
1974
|
-
// Field-level touch merge preserves the newest value for every touch
|
|
1975
|
-
// field when full-snapshot appends from separate processes interleave.
|
|
2183
|
+
if (!record) return;
|
|
1976
2184
|
const prior = records.get(record.namespace);
|
|
1977
2185
|
records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
|
|
2186
|
+
};
|
|
2187
|
+
for (;;) {
|
|
2188
|
+
const { bytesRead } = await handle.read(chunk, 0, CHUNK_BYTES, null);
|
|
2189
|
+
if (bytesRead === 0) break;
|
|
2190
|
+
leftover += decoder.write(chunk.subarray(0, bytesRead));
|
|
2191
|
+
let newlineIndex = leftover.indexOf("\n");
|
|
2192
|
+
while (newlineIndex !== -1) {
|
|
2193
|
+
foldLine(leftover.slice(0, newlineIndex));
|
|
2194
|
+
leftover = leftover.slice(newlineIndex + 1);
|
|
2195
|
+
if (++linesSinceYield >= YIELD_EVERY_LINES) {
|
|
2196
|
+
linesSinceYield = 0;
|
|
2197
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
2198
|
+
}
|
|
2199
|
+
newlineIndex = leftover.indexOf("\n");
|
|
2200
|
+
}
|
|
1978
2201
|
}
|
|
2202
|
+
leftover += decoder.end();
|
|
2203
|
+
foldLine(leftover);
|
|
1979
2204
|
} catch {
|
|
1980
2205
|
this.compactedCache = undefined;
|
|
1981
2206
|
return new Map();
|