diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 5e93cd96..da5887f6 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -5,6 +5,10 @@ name: CI
# sequential, so tag-triggered matrix jobs (~22 min) would queue AHEAD of the
# tag's publish-source run and starve every release (observed on 8.10.3 and
# 9.0.0: the publish sat behind the tag's own redundant CI).
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
on:
push:
branches: ['**']
diff --git a/.forgejo/workflows/publish-source.yml b/.forgejo/workflows/publish-source.yml
index 58cb1d30..6bd42b2a 100644
--- a/.forgejo/workflows/publish-source.yml
+++ b/.forgejo/workflows/publish-source.yml
@@ -12,6 +12,11 @@ on:
push:
tags:
- 'v*'
+ workflow_dispatch:
+ inputs:
+ ref_reason:
+ description: 'why this manual run (e.g. tag event dropped)'
+ required: false
jobs:
publish:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16fb5786..c4f89332 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,57 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
+
+### [10.4.13](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.12...v10.4.13) (2026-09-03)
+
+- A shutdown that holds its listener until the exit decision, and a test suite that closes every brain it opens
+- fix(shutdown): the engine's signal handler keeps its listener registered until the exit decision is made — closing the last live instance no longer deregisters the handler mid-run, so a second signal delivery during a clean shutdown can never kill the process after the work is done (a2ea21b3)
+- fix(release): the release wall entry commits under an explicit git identity read from the developer's checkout; a host with no identity refuses by name instead of failing inside git (aac853d3)
+- test(hygiene): every brain a test file creates is closed by that file — 40 files fixed, the leaks that let a stray cadence narrate into later files are gone; brains whose init() was expected to fail are closed too (6eb5e448)
+
+### [10.4.12](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.11...v10.4.12) (2026-09-03)
+
+- Mixed-kind fields index exactly, arrays to 256, a drained loop is not a shutdown, and finds project from the column store
+- fix(index): a metadata field holds every value kind it was written with — one posting column per (field, kind); an equality filter reads the query value's own kind, a range routes by its bounds; nothing is refused and nothing is silently dropped; an index written by the old shape opens unchanged (a128f0ed)
+- fix(metadata): metadata arrays index up to 256 elements; a longer array refuses at write time by name (MetadataArrayTooLargeError) — a vector parked in metadata now throws; move it to `vector` (e435da78)
+- fix(shutdown): beforeExit runs a non-closing flush only — a script that never calls close() exits with the writer lock on disk and no clean-shutdown marker, and the next open evicts the stale lock and folds the log, bounded; SIGTERM and SIGINT are unchanged (6baa4d7f)
+- feat(find): field projection — find({fields}) and get({fields}) resolve scalars from the column store on every leg, including vector-leg finds; absent fields stay absent (ad0f493f)
+- fix(find): orderBy is the order on every find path, not only the metadata-only one (5e720d17)
+- fix(metadata): the legacy sparse range path orders values, or refuses by name — never ranks by hash (a7eb7f52)
+- fix(close): a read-only brain writes nothing under `_system/` (f27a7776)
+- fix(contract): the flush gate's internals are private, not doors (72c8ee6a)
+- test(hygiene): the triple-intelligence correctness cases sit in the gate; the idle and connected-find pins name the brain they measure (28083981)
+- ci(release): the rail writes its own wall entry into the shared releases repo — never hand-written again (adcb883e)
+
+### [10.4.11](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.9...v10.4.11) (2026-09-02)
+
+- ci: superseded pushes cancel their own runs (concurrency per ref) (6053f6d4)
+- test(batch): the batch-size-limit tests add unvectored items — they test batching, not embedding (a1423c6d)
+- fix(flush): the gate settles its waiter from the machine, never from a chain (dea3ec20)
+- test(batch): the batch-vs-individual timing assertion runs in the perf lane, not the correctness gate (ebb3a4bf)
+- test(gate): the coverage guard counts the perf lane's config as a gate (2c5e3474)
+- chore(contract): emit the 10.4.11 manifest (4142f368)
+- fix(close): a read-only brain writes no clean-shutdown evidence — the marker is the writer's word about itself (367ca721)
+- fix(generation-store): commitTransaction refuses while single-ops are pending — the order invariant is enforced, not assumed (a79db434)
+- test(shutdown): pin one owner per brain — real processes, real signals (da951990)
+- fix(shutdown): one owner per brain — the signal handler defers to close(), and flush is single-flight (ec644bde)
+- fix(vfs): a path-scoped search is a served range over the path, not a refused prefix match (65493ba2)
+- ci(test): perf and scale benchmarks leave the correctness gate (dee46b35)
+- test(open): pin the pending-embed checkpoint — stuck id, crash matrix, torn fallback (1fb51093)
+- perf(open): the pending-embed fold is bounded by a checkpoint of the SET, not an empty-only mark (15d4f65d)
+- perf(open): a sealed segment the manifest proves is below the bound is never read (bc70c43d)
+- fix(find): a page the metadata block already cut is not cut again (905c267c)
+- fix(find): the hybrid legs rank inside the filter, and only the page is read (b1c70544)
+- ci(delta-gate): add a push fallback trigger alongside workflow_dispatch (67ae0046)
+- ci: add the delta-gate workflow for the capped functional lane (9922631d)
+- docs(plugin): the planner door's hiddenIds contract is the answer, not the mechanism (2633e8d5)
+- feat(engine): a protected factory for the generation store — a subclass may substitute one that keeps the contract (f763317a)
+- fix(find): near() searches around the anchor's own vector, and refuses by name without one (a8c5fbf9)
+- Merge remote-tracking branches 'origin/fix/planner-provider-door' and 'origin/fix/containment-batching' into rel/10.4.10-candidate (34f1886f)
+- feat(plugin): an optional planFindPage door — an index that can plan a find answers it in one call (4d5f823f)
+- perf(vfs): repairContainment's reconcile is one paged edge walk, not one graph call per file (3e60aded)
+
+
### [10.4.9](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.6...v10.4.9) (2026-09-02)
- Merge branch 'fix/pending-embed-low-water' into rel/10.4.9-candidate (2648f56d)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 54d4f784..c58520b7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,6 +41,20 @@ npm test
Tests run on [Vitest](https://vitest.dev/). `npm test` runs the unit suite;
see `package.json` for `test:integration`, `test:coverage`, and friends.
+## Test gate
+
+The release gate is a bare `vitest run` (no `--config` flag) — the same
+command the delta gate and CI's checks invoke. It carries the full
+correctness suite and nothing else: wall-clock/scale benchmarks
+(`tests/performance/**`, `tests/critical-performance-benchmark.test.ts`,
+`tests/api/performance-benchmarks.test.ts`) and the two tests whose outcome
+depends on the host machine or network rather than the code
+(`tests/package-size-limit.test.ts` shells out to the `npm` CLI;
+`tests/model-loading.test.ts` makes a real network call to download a model)
+are excluded from it, because a timing threshold or a flaky network call has
+no business failing a correctness check. That whole family runs on demand,
+in its own exclusive slot, via `npm run test:perf`.
+
## Standards
- **Strict TypeScript.** No `any` escape hatches to dodge the type checker.
diff --git a/README.md b/README.md
index 762c9ec3..fbf129ac 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,11 @@
Brainy
+> **Frozen at 10.4.13 (2026-09-03).** This repository is the reference implementation of the Brainy store format and API,
+> published under the MIT license. Version 10.4.13 is its last release; the repository is read-only from here. The engine
+> continues as `@soulcraft/brainy`, which bundles this layer as owned code; every published version of this package stays
+> available on The Source. Use this repository to read a Brainy store independently or to verify the conformance contract.
+
Three database paradigms. One API. Zero configuration.
The in-process knowledge database for TypeScript — vector search, graph traversal,
diff --git a/RELEASES.md b/RELEASES.md
index e8833b80..64e64873 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,5 +1,15 @@
# @soulcraft/brainy — Release Notes for Consumers
+> **Frozen at 10.4.13 (2026-09-03).** 10.4.13 is the last release of `@soulcraftlabs/brainy`; this repository is read-only from here.
+> Release notes for the product engine continue on its own wall.
+
+Machine-readable release notes are published at
+https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/open-brainy.json
+(this engine) and
+https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/brainy.json
+(the product engine) — read by HQ's `/hq/releases` door, and the source of
+truth ahead of this file.
+
This file is the **quick reference for downstream sessions** tracking Brainy changes.
Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraftlabs/open-brainy/releases
diff --git a/docs/FIND_SYSTEM.md b/docs/FIND_SYSTEM.md
index 77fbbd79..6aa33515 100644
--- a/docs/FIND_SYSTEM.md
+++ b/docs/FIND_SYSTEM.md
@@ -369,6 +369,71 @@ return results.slice(offset, offset + limit)
// → Auto-correction: Use most likely alternative based on affinity data
```
+## Field Projection (`fields`)
+
+`find()` and `get()` accept a `fields` list. Without it they return the whole
+record; with it they return only the fields you name — and, where the index can
+supply them, without opening the canonical record at all.
+
+```ts
+// A list page: two user fields and one engine scalar. No document bodies.
+await brain.find({
+ where: { kind: 'post' },
+ fields: ['title', 'slug', 'system.createdAt'],
+ limit: 50
+})
+
+await brain.get(id, { fields: ['title'] })
+```
+
+### Why it exists
+
+A list view that renders a title and a date does not need the body, but without
+a projection every row hydrates its full record and throws almost all of it
+away. On a posts list that is the dominant cost of the query.
+
+### The rules
+
+| | |
+|---|---|
+| **`fields` absent** | The full record, byte-identical to before. Nothing changes. |
+| **Field names** | The one addressing law: a bare name is user metadata (`'title'`), `system.*` is an engine scalar (`'system.createdAt'`). |
+| **A field the row lacks** | Simply **absent** from the result. Never an error. |
+| **Identity** | Every row keeps its `id` (and `score` on `find`) regardless — a row you cannot identify is not a row. |
+| **Where values come from** | The **column store**, which holds raw values. Never the sparse index, which buckets timestamps for range queries. |
+| **A field the column cannot serve** | The canonical record is read for that field only. Correct, just not free. |
+
+### Missing fields are absent, not errors
+
+This is deliberate and differs from `orderBy`, which throws
+`UnresolvableFieldError` for an unknown field. A typo in `orderBy` silently
+changes the ordering, so it must be loud. A projection asks "give me these if
+you have them", and an optional field must not turn a list into a failure — so
+`fields` uses the permissive path.
+
+### Cost
+
+When every named field is column-served, a projected page performs **zero**
+canonical reads. When one is not, only that read happens and the rest still come
+from the index. Both are pinned by counting reads rather than timing them, in
+`tests/integration/find-fields-projection.test.ts`.
+
+### `related()` takes no `fields`
+
+A `Relation` carries `from` and `to` as **ids** and hydrates no entity record,
+so there is nothing for a projection to trim. Projecting the endpoints would be
+a new capability rather than a projection of an existing one.
+
+### For engine implementers
+
+Projection is served through an optional provider door,
+`getScalarsForIds(ids, fields)` on `MetadataIndexProvider`. The contract is in
+`src/plugin.ts`; the short version is **return only what you can serve exactly,
+and say what you served**. The caller diffs the answer against the request and
+reads records for the remainder, so omission costs a read while a wrong value is
+a wrong answer nobody can see. An engine without the door still works — every
+field falls back to the record.
+
## Performance Characteristics
### Query Performance by Type
diff --git a/docs/api-contract.json b/docs/api-contract.json
index aafd838a..c4f4e056 100644
--- a/docs/api-contract.json
+++ b/docs/api-contract.json
@@ -161,6 +161,11 @@
"kind": "method",
"arity": 1
},
+ {
+ "name": "captureEmbedCheckpoint",
+ "kind": "method",
+ "arity": 0
+ },
{
"name": "checkHealth",
"kind": "method",
@@ -225,6 +230,11 @@
"name": "counts",
"kind": "accessor"
},
+ {
+ "name": "createGenerationStore",
+ "kind": "method",
+ "arity": 1
+ },
{
"name": "createIndex",
"kind": "method",
@@ -258,6 +268,11 @@
"kind": "method",
"arity": 1
},
+ {
+ "name": "demoteTornEntityTreeStamp",
+ "kind": "method",
+ "arity": 4
+ },
{
"name": "detectIdKind",
"kind": "method",
@@ -353,11 +368,6 @@
"kind": "method",
"arity": 1
},
- {
- "name": "executeGraphSearch",
- "kind": "method",
- "arity": 2
- },
{
"name": "executeProximitySearch",
"kind": "method",
@@ -368,11 +378,21 @@
"kind": "method",
"arity": 2
},
+ {
+ "name": "executeTextSearchScored",
+ "kind": "method",
+ "arity": 3
+ },
{
"name": "executeVectorSearch",
"kind": "method",
"arity": 3
},
+ {
+ "name": "executeVectorSearchScored",
+ "kind": "method",
+ "arity": 3
+ },
{
"name": "explain",
"kind": "method",
@@ -418,6 +438,11 @@
"kind": "method",
"arity": 2
},
+ {
+ "name": "filterIdsWithinBelted",
+ "kind": "method",
+ "arity": 2
+ },
{
"name": "find",
"kind": "method",
@@ -711,6 +736,11 @@
"kind": "method",
"arity": 2
},
+ {
+ "name": "hydrateResultPage",
+ "kind": "method",
+ "arity": 2
+ },
{
"name": "import",
"kind": "method",
@@ -741,6 +771,14 @@
"kind": "method",
"arity": 0
},
+ {
+ "name": "isClosed",
+ "kind": "accessor"
+ },
+ {
+ "name": "isClosing",
+ "kind": "accessor"
+ },
{
"name": "isEmbeddingReady",
"kind": "method",
@@ -799,6 +837,16 @@
"kind": "method",
"arity": 1
},
+ {
+ "name": "maybeWriteEmbedCheckpoint",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "maybeWriteEmbedLowWater",
+ "kind": "method",
+ "arity": 0
+ },
{
"name": "metadataIndexRetractionOp",
"kind": "method",
@@ -854,6 +902,11 @@
"kind": "method",
"arity": 1
},
+ {
+ "name": "noteEmbedCheckpointCadence",
+ "kind": "method",
+ "arity": 0
+ },
{
"name": "noteWriteForPersistence",
"kind": "method",
@@ -869,6 +922,11 @@
"kind": "method",
"arity": 1
},
+ {
+ "name": "pageConnectedIds",
+ "kind": "method",
+ "arity": 2
+ },
{
"name": "pagination",
"kind": "accessor"
@@ -893,6 +951,11 @@
"kind": "method",
"arity": 0
},
+ {
+ "name": "pendingResult",
+ "kind": "method",
+ "arity": 2
+ },
{
"name": "performInit",
"kind": "method",
@@ -993,6 +1056,11 @@
"kind": "method",
"arity": 2
},
+ {
+ "name": "readPendingEmbedBound",
+ "kind": "method",
+ "arity": 0
+ },
{
"name": "ready",
"kind": "accessor"
@@ -1112,6 +1180,11 @@
"kind": "method",
"arity": 2
},
+ {
+ "name": "resolveConnectedIds",
+ "kind": "method",
+ "arity": 1
+ },
{
"name": "resolveDiffEndpoint",
"kind": "method",
@@ -1155,7 +1228,7 @@
{
"name": "rrfFusion",
"kind": "method",
- "arity": 4
+ "arity": 3
},
{
"name": "runAggregationBackfillWalk",
@@ -1275,6 +1348,11 @@
"kind": "method",
"arity": 1
},
+ {
+ "name": "textIdsWithinBelted",
+ "kind": "method",
+ "arity": 2
+ },
{
"name": "trackField",
"kind": "method",
@@ -1413,12 +1491,23 @@
"name": "wireGraphIdResolver",
"kind": "method",
"arity": 0
+ },
+ {
+ "name": "writeEmbedCheckpoint",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "writeEmbedLowWater",
+ "kind": "method",
+ "arity": 0
}
],
"errors": [
"BrainyError",
"DerivedArtifactMissingError",
"GraphIndexNotReadyError",
+ "MetadataArrayTooLargeError",
"MetadataIndexNotReadyError",
"MigrationInProgressError",
"ProtectedArtifactError",
diff --git a/docs/architecture/data-storage-architecture.md b/docs/architecture/data-storage-architecture.md
index 83b9e23a..12398747 100644
--- a/docs/architecture/data-storage-architecture.md
+++ b/docs/architecture/data-storage-architecture.md
@@ -217,6 +217,40 @@ membership queries at scale:
`__words__` for tokenized text…).
- `_blobs/_column_index/{field}/L0-NNNNNN.bin` — the actual level-0 run
segments, stored through the shared `_blobs/.bin` binary convention.
+- `_column_index/{field}/k/{kind}/…` — the same two files again, for a
+ **second value kind** on the same field (see below). Absent for a field that
+ holds one kind, which is nearly all of them.
+
+### One posting column per (field, kind)
+
+A field is not obliged to hold one type of value. `category` may carry
+`'electronics'` on some rows and `5` on others, and both are real values of
+that field. A segment, though, has one encoding — i64, f64, UTF-8, or boolean
+— so a field that holds several kinds gets **one column per kind**:
+
+- The first kind a field ever sees owns the plain `_column_index/{field}/`
+ layout above. A single-kind field is therefore byte-identical to what earlier
+ versions wrote, and an index written before typed postings opens unchanged.
+- Every later kind gets its own column beside it at
+ `_column_index/{field}/k/{kind}/`, where `{kind}` is `number`, `string` or
+ `boolean`.
+
+What that buys at query time:
+
+| | |
+|---|---|
+| **Equality** | Answered from the column matching the **query value's own kind**. `where {category: 5}` reads the number postings; `where {category: '5'}` reads the string postings. Neither borrows the other's rows — a row written with the number `5` is not a row whose category is the text `'5'`. |
+| **A kind the field never held** | Matches nothing. That is the true answer, not a coerced one. |
+| **Ranges** | Routed by the kind of the bounds: numeric bounds read the numeric postings and ignore the field's strings. An **unbounded** range is the "has any value here" probe behind `exists`, and reads every kind. |
+| **`orderBy`** | A number and a string have no order between them, so a mixed field orders by kind first (number, string, boolean) and by value within a kind. A single-kind field sorts exactly as it always did. |
+| **Numbers** | One kind, one column: an integer column is written as i64 and widens to f64 the first time a non-integer arrives, so `4.5` is stored as itself rather than rounded. |
+
+`null` and `undefined` are not kinds and are never posted; their absence is
+what the `exists` / `missing` operators read.
+
+Older readers are unaffected by the additional columns: they see the field's
+primary column exactly where it has always been, and a `k/{kind}` directory is
+simply a name they never query.
Sparse per-field indexes, roaring-bitmap chunks, and zone-map/bloom segments
additionally live as bucketed keys under `_system/idx/` (see §3). Which path
diff --git a/docs/concepts/multi-process.md b/docs/concepts/multi-process.md
index 8fda315f..d698eee8 100644
--- a/docs/concepts/multi-process.md
+++ b/docs/concepts/multi-process.md
@@ -95,8 +95,15 @@ The heartbeat interval rewrites the lock file every 10 seconds. The timer
is unref'd, so it does not keep the event loop alive on its own.
On normal shutdown the writer releases the lock in `close()`. The shutdown
-hooks Brainy registers for `SIGTERM`, `SIGINT`, and `beforeExit` also
-release the lock so a container restart doesn't strand the directory.
+hooks Brainy registers for `SIGTERM` and `SIGINT` close every live brain by
+that same `close()`, so a container restart doesn't strand the directory.
+
+`beforeExit` is not one of them. Node emits it whenever the event loop has
+no ref'd work left — a state a healthy script reaches routinely, because
+Brainy's own idle and cadence timers are unref'd — and a drained event loop
+is not a shutdown. That hook only persists derived state with a non-closing
+`flush()`: it closes nothing, releases no lock, and leaves every brain open
+and usable. If you want a shutdown, call `close()` or send `SIGTERM`.
## How to inspect a live writer
diff --git a/package-lock.json b/package-lock.json
index fc530baa..bd12e46c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@soulcraftlabs/brainy",
- "version": "10.4.9",
+ "version": "10.4.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@soulcraftlabs/brainy",
- "version": "10.4.9",
+ "version": "10.4.13",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
diff --git a/package.json b/package.json
index f07bb94c..a3bd0483 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@soulcraftlabs/brainy",
- "version": "10.4.9",
+ "version": "10.4.13",
"brainyContract": 1,
"description": "Universal Knowledge Protocol™ - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns × 127 verbs covering 96-97% of all human knowledge.",
"main": "dist/index.js",
@@ -88,7 +88,7 @@
"test:watch": "NODE_OPTIONS='--max-old-space-size=8192' vitest --config tests/configs/vitest.unit.config.ts",
"test:coverage": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.unit.config.ts --coverage",
"test:unit": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.unit.config.ts",
- "test:perf": "vitest run tests/unit/performance --reporter=basic",
+ "test:perf": "vitest run --config tests/configs/vitest.perf.config.ts",
"test:integration": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.integration.config.ts",
"test:semantic": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.semantic.config.ts",
"test:all": "npm run test:unit && npm run test:integration",
diff --git a/scripts/release.sh b/scripts/release.sh
index 1a4fe575..a9a1f6e9 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -154,13 +154,26 @@ else
fi
# Create new changelog entry
-CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
+RELEASE_DATE=$(date +%Y-%m-%d)
+CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) (${RELEASE_DATE})
${COMMITS}
"
+# A CURATED entry wins over the generated one. When a release is cut from a
+# lineage that diverged from the previous tag (a candidate branch carrying
+# main's history), `git log ..HEAD` lists every commit the tag never
+# saw — old notes, already-shipped fixes under new hashes, merge commits — and a
+# wall entry derived from it would misreport the release. If CHANGELOG.md
+# already carries a `### [NEW_VERSION]` heading, it was written on purpose:
+# keep it, and skip the generated prepend entirely.
+CURATED_ENTRY=false
+if grep -qE "^### \[${NEW_VERSION}\]" CHANGELOG.md 2>/dev/null; then
+ CURATED_ENTRY=true
+ echo -e "${YELLOW}CHANGELOG already carries a curated ### [${NEW_VERSION}] entry — keeping it, not generating one from commits${NC}"
+fi
# Prepend to CHANGELOG.md after header
-if [ -f "CHANGELOG.md" ]; then
+if [ "$CURATED_ENTRY" = false ] && [ -f "CHANGELOG.md" ]; then
# Read header (first 4 lines)
HEADER=$(head -n 4 CHANGELOG.md)
# Read rest of file
@@ -174,6 +187,19 @@ if [ -f "CHANGELOG.md" ]; then
fi
echo -e "${GREEN}✅ CHANGELOG updated${NC}\n"
+# Step 6b: Update the releases wall entry — mechanical, derived from the
+# CHANGELOG entry just composed. The fleet's HQ page reads open-brainy.json
+# from the one shared releases repo, soulcraftlabs/releases on The Source —
+# this used to be hand-written after every release (David: never again —
+# make it a step of the rail, landed in the one shared home; this repo no
+# longer hosts its own copy). This step clones/fetches that repo into a
+# local cache, prepends the entry, and pushes it directly — a real
+# cross-repo push, refusing loudly (never skipping) on any
+# clone/validation/commit/push failure.
+echo -e "${BLUE}5️⃣▸ Updating the releases wall...${NC}"
+node scripts/wall-entry.mjs --product open-brainy --version "${NEW_VERSION}" --date "${RELEASE_DATE}" --from-changelog CHANGELOG.md
+echo -e "${GREEN}✅ Releases wall updated${NC}\n"
+
# Step 7: Create release commit
echo -e "${BLUE}6️⃣ Creating release commit...${NC}"
git add package.json package-lock.json CHANGELOG.md
@@ -237,7 +263,7 @@ fi
# and RELEASES.md are the record; this just gives The Source's UI a release page).
echo -e "${BLUE}🔟 Creating release page on The Source...${NC}"
if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then
- if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/brainy/releases" \
+ if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraftlabs/open-brainy/releases" \
-H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then
echo -e "${GREEN}✅ Release page created on The Source${NC}\n"
diff --git a/scripts/wall-entry.mjs b/scripts/wall-entry.mjs
new file mode 100644
index 00000000..5079cf86
--- /dev/null
+++ b/scripts/wall-entry.mjs
@@ -0,0 +1,539 @@
+#!/usr/bin/env node
+/**
+ * @module scripts/wall-entry
+ * @description The releases-wall entry, made mechanical. The fleet's HQ page
+ * reads one public JSON per product from the ONE releases repo on The Source
+ * (soulcraftlabs/releases, files .json at its root — shape
+ * {product, entries:[{version, date, headline, items, url, thumb?}]}), at
+ * https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/.json.
+ * Those entries were hand-written after every release, then briefly written
+ * into this repo's own releases/.json; this script is the one door
+ * that composes an entry and lands it in the shared repo, so it is never
+ * hand-written and never forked across repos again.
+ *
+ * Two modes:
+ *
+ * 1. Generate + publish (default):
+ * node wall-entry.mjs --product --version --date \
+ * --from-changelog
+ * Derives an entry from the CHANGELOG.md entry for (headline = the
+ * entry's first bullet, items = every bullet, trimmed of its trailing
+ * commit hash), then:
+ * - clones (or, if a cached clone already exists, fetches and resets)
+ * the releases repo into a local cache directory,
+ * - prepends the entry to /.json, newest first — replacing
+ * any existing entry for the same version so a re-run is idempotent,
+ * - validates the file's shape before and after,
+ * - commits the change as "chore(wall):
" and pushes main.
+ * A failure at any step (clone, validation, commit, push, a
+ * non-fast-forward remote) exits non-zero naming the cure. Nothing is
+ * ever skipped — the wall either lands correctly or the release fails.
+ *
+ * 2. Dry run:
+ * node wall-entry.mjs --dry-run --product --version \
+ * --date --from-changelog
+ * Derives the entry exactly as above and prints it, along with the file
+ * it would be written to, but touches no clone and no remote — usable
+ * from a fresh checkout with no cache and no network.
+ *
+ * 3. Validate only (--check):
+ * node wall-entry.mjs --check --file
+ * Validates an arbitrary wall file's exact key set (top-level and
+ * per-entry), field types, and strict-descending semver ordering with
+ * no duplicates. Read-only; never writes. Exit 0 = clean, exit 1 =
+ * named violations printed to stderr.
+ *
+ * The remote and the local cache directory are each overridable
+ * (--remote / --cache-dir, or WALL_ENTRY_RELEASES_REMOTE /
+ * WALL_ENTRY_RELEASES_CACHE_DIR) so tests can point at a throwaway local
+ * bare repo and a throwaway cache directory — never the real remote or the
+ * real developer cache.
+ *
+ * No dependencies beyond the system `git` binary — CHANGELOG parsing,
+ * semver comparison, and JSON shape checking are all hand-rolled below.
+ */
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
+import { execFileSync } from 'node:child_process'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+const DEFAULT_REMOTE = 'git@source.soulcraft.com:soulcraftlabs/releases.git'
+
+/** @returns {string} */
+function defaultCacheDir() {
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache')
+ return join(base, 'soulcraft-releases')
+}
+
+// Required on every entry; "thumb" is optional (may be absent, or present as
+// string | null) — matching the HQ contract's {..., thumb?}.
+const ENTRY_REQUIRED_KEYS = ['version', 'date', 'headline', 'items', 'url']
+const ENTRY_OPTIONAL_KEYS = ['thumb']
+const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS]
+const FILE_KEYS = ['product', 'entries']
+
+// The public permalink pattern, by product. Every entry MUST carry an https
+// permalink: HQ's parser rejects a wall whose entries carry url: null (the
+// whole feed became unreadable on 2026-09-02). A product whose forge repo is
+// private links its PUBLIC package page on The Source instead of a release
+// page that would 404 for HQ's readers.
+const RELEASE_URL_PATTERNS = {
+ 'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`,
+ 'brainy': (version) => `https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/${version}`,
+}
+
+/**
+ * Parse argv into a flag map. `--flag value` sets a string; `--flag` alone
+ * (end of argv, or followed by another `--flag`) sets boolean true.
+ * @param {string[]} argv
+ * @returns {Record}
+ */
+function parseArgs(argv) {
+ /** @type {Record} */
+ const args = {}
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i]
+ if (!a.startsWith('--')) continue
+ const key = a.slice(2)
+ const next = argv[i + 1]
+ if (next === undefined || next.startsWith('--')) {
+ args[key] = true
+ } else {
+ args[key] = next
+ i++
+ }
+ }
+ return args
+}
+
+/**
+ * Print a loud, named error and exit 1. Every refusal in this script goes
+ * through here so the failure mode is always the same shape: "wall-entry: ".
+ * @param {string} message
+ * @returns {never}
+ */
+function fail(message) {
+ console.error(`wall-entry: ${message}`)
+ process.exit(1)
+}
+
+/**
+ * @param {string} version
+ * @returns {{major: number, minor: number, patch: number, pre: string | null} | null}
+ */
+function parseSemver(version) {
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version)
+ if (!m) return null
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
+}
+
+/**
+ * @param {string} a
+ * @param {string} b
+ * @returns {number} positive if a > b, negative if a < b, 0 if equal.
+ */
+function compareSemver(a, b) {
+ const pa = parseSemver(a)
+ const pb = parseSemver(b)
+ if (!pa || !pb) throw new Error(`cannot compare non-semver versions "${a}" vs "${b}"`)
+ if (pa.major !== pb.major) return pa.major - pb.major
+ if (pa.minor !== pb.minor) return pa.minor - pb.minor
+ if (pa.patch !== pb.patch) return pa.patch - pb.patch
+ if (pa.pre === pb.pre) return 0
+ if (pa.pre === null) return 1 // a release outranks any prerelease of the same core version
+ if (pb.pre === null) return -1
+ return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0
+}
+
+/**
+ * Validate a wall file's full shape: top-level keys ("product", "entries" —
+ * no more, no less), per-entry keys and field types ("thumb" optional), and
+ * strict-descending semver ordering with no duplicates. Collects every
+ * violation instead of failing on the first, so a caller reports the whole
+ * picture in one pass.
+ * @param {unknown} data
+ * @returns {string[]} Violation messages; empty means the file is clean.
+ */
+function validateShape(data) {
+ /** @type {string[]} */
+ const errors = []
+
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
+ return ['top level: expected a JSON object']
+ }
+ const obj = /** @type {Record} */ (data)
+
+ const topKeys = Object.keys(obj)
+ const missingTop = FILE_KEYS.filter((k) => !(k in obj))
+ const extraTop = topKeys.filter((k) => !FILE_KEYS.includes(k))
+ if (missingTop.length) errors.push(`top level: missing key(s) ${missingTop.join(', ')}`)
+ if (extraTop.length) errors.push(`top level: unexpected key(s) ${extraTop.join(', ')}`)
+
+ if (typeof obj.product !== 'string' || obj.product.trim() === '') {
+ errors.push('top level: "product" must be a non-empty string')
+ }
+ if (!Array.isArray(obj.entries)) {
+ errors.push('top level: "entries" must be an array')
+ return errors // nothing further to check without an array
+ }
+
+ const entries = /** @type {unknown[]} */ (obj.entries)
+ entries.forEach((rawEntry, i) => {
+ const label = `entries[${i}]`
+ if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) {
+ errors.push(`${label}: expected an object`)
+ return
+ }
+ const entry = /** @type {Record} */ (rawEntry)
+ const keys = Object.keys(entry)
+ const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry))
+ const extra = keys.filter((k) => !ENTRY_ALLOWED_KEYS.includes(k))
+ if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`)
+ if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`)
+
+ if (typeof entry.version !== 'string' || !parseSemver(entry.version)) {
+ errors.push(`${label}: "version" must be a semver string (got ${JSON.stringify(entry.version)})`)
+ }
+ if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(entry.date))) {
+ errors.push(`${label}: "date" must be a YYYY-MM-DD string (got ${JSON.stringify(entry.date)})`)
+ }
+ if (typeof entry.headline !== 'string' || entry.headline.trim() === '') {
+ errors.push(`${label}: "headline" must be a non-empty string`)
+ }
+ if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) {
+ errors.push(`${label}: "items" must be a non-empty array of non-empty strings`)
+ }
+ if (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) {
+ errors.push(`${label}: "url" must be an https permalink — never null; HQ's parser rejects the whole feed`)
+ }
+ if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) {
+ errors.push(`${label}: "thumb" must be a string or null when present`)
+ }
+ })
+
+ // Ordering: newest first, strictly descending, no duplicate versions —
+ // checked only over entries whose version parsed (a bad version is
+ // already reported above; comparing it too would just be noise).
+ const versioned = entries
+ .map((e, i) => ({ i, version: /** @type {any} */ (e)?.version }))
+ .filter((e) => typeof e.version === 'string' && parseSemver(e.version))
+ for (let i = 0; i < versioned.length - 1; i++) {
+ const a = versioned[i]
+ const b = versioned[i + 1]
+ const cmp = compareSemver(a.version, b.version)
+ if (cmp === 0) {
+ errors.push(`entries[${a.i}] and entries[${b.i}]: duplicate version ${a.version}`)
+ } else if (cmp < 0) {
+ errors.push(`entries[${a.i}] (${a.version}) sits above entries[${b.i}] (${b.version}) — not newest-first`)
+ }
+ }
+
+ return errors
+}
+
+/**
+ * Extract one version's entry body from a standard-version-style CHANGELOG.md
+ * (headings `### [version](url) (date)`, followed by `- bullet (hash)` lines
+ * until the next heading or EOF).
+ * @param {string} changelog
+ * @param {string} version
+ * @returns {string[]} Bullet lines, trimmed of their leading "- " and
+ * trailing " (hash)".
+ */
+function extractChangelogBullets(changelog, version) {
+ const lines = changelog.split('\n')
+ const headingRe = /^### \[([^\]]+)\]\(.*\)\s*\(\d{4}-\d{2}-\d{2}\)\s*$/
+ let start = -1
+ for (let i = 0; i < lines.length; i++) {
+ const m = headingRe.exec(lines[i])
+ if (m && m[1] === version) {
+ start = i + 1
+ break
+ }
+ }
+ if (start === -1) {
+ fail(
+ `version ${version} has no CHANGELOG entry yet — run this after the CHANGELOG step composes "### [${version}]", not before`,
+ )
+ }
+ /** @type {string[]} */
+ const bullets = []
+ for (let i = start; i < lines.length; i++) {
+ if (headingRe.test(lines[i])) break // next entry starts
+ const bulletMatch = /^- (.+?)(?:\s\(([0-9a-f]{6,40})\))?$/.exec(lines[i].trim())
+ if (lines[i].trim().startsWith('- ') && bulletMatch) {
+ const text = bulletMatch[1].trim()
+ if (text) bullets.push(text)
+ }
+ }
+ if (bullets.length === 0) {
+ fail(`version ${version}'s CHANGELOG entry has no bullets to derive a headline/items from`)
+ }
+ return bullets
+}
+
+/**
+ * Derive a wall entry from a CHANGELOG.md.
+ * @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts
+ * @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}}
+ */
+function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
+ if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`)
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
+ fail(`--date "${date}" is not a YYYY-MM-DD date`)
+ }
+ if (!existsSync(changelogPath)) fail(`--from-changelog "${changelogPath}" does not exist`)
+
+ const changelog = readFileSync(changelogPath, 'utf8')
+ const items = extractChangelogBullets(changelog, version)
+ const headline = items[0]
+
+ const pattern = RELEASE_URL_PATTERNS[product]
+ if (url === undefined && pattern === undefined) {
+ throw new Error(`wall-entry: no permalink pattern for product "${product}" — add one to RELEASE_URL_PATTERNS or pass --url; entries never carry url: null`)
+ }
+ const resolvedUrl = url !== undefined ? url : pattern(version)
+ const resolvedThumb = thumb !== undefined ? thumb : null
+
+ return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb }
+}
+
+/**
+ * Load and shape-validate a wall file.
+ * @param {string} filePath
+ * @returns {Record}
+ */
+function loadWallFile(filePath) {
+ if (!existsSync(filePath)) fail(`"${filePath}" does not exist`)
+ /** @type {unknown} */
+ let data
+ try {
+ data = JSON.parse(readFileSync(filePath, 'utf8'))
+ } catch (err) {
+ fail(`"${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`)
+ }
+ const errors = validateShape(data)
+ if (errors.length) {
+ fail(`"${filePath}" fails shape validation —\n ${errors.join('\n ')}`)
+ }
+ return /** @type {Record} */ (data)
+}
+
+/**
+ * Run a git command, throwing an Error whose message is git's own stderr
+ * (trimmed) on failure — every caller wraps this to name the cure.
+ * @param {string[]} args
+ * @param {string} cwd
+ * @returns {string} stdout, trimmed.
+ */
+function git(args, cwd) {
+ try {
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
+ } catch (err) {
+ const stderr = /** @type {any} */ (err).stderr
+ const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message
+ throw new Error(message)
+ }
+}
+
+/**
+ * Resolve the git identity for the wall commit from the repository the rail
+ * is actually running in — the developer's own checkout (`process.cwd()`;
+ * `release.sh` invokes this script from the repo root with no `cd`), via
+ * git's normal config precedence (repo-local, then global, then system).
+ * Never guessed and never left to git's own "who are you?" prompt: a host
+ * with no configured identity anywhere (a bare CI box, say) must refuse
+ * loudly rather than have git manufacture a placeholder identity or hang.
+ * @returns {{name: string, email: string}}
+ */
+function resolveWallCommitIdentity() {
+ const repo = process.cwd()
+ let name = ''
+ let email = ''
+ try {
+ name = git(['config', 'user.name'], repo)
+ } catch {
+ name = ''
+ }
+ try {
+ email = git(['config', 'user.email'], repo)
+ } catch {
+ email = ''
+ }
+ if (!name || !email) {
+ fail('no git identity for the wall commit — set user.name/user.email')
+ }
+ return { name, email }
+}
+
+/**
+ * Ensure a clean, up-to-date local clone of the releases repo at
+ * `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no
+ * `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a
+ * stray local commit or edit left by a previous failed run can never leak
+ * into the next one).
+ * @param {string} remote
+ * @param {string} cacheDir
+ */
+function ensureReleasesClone(remote, cacheDir) {
+ if (existsSync(join(cacheDir, '.git'))) {
+ try {
+ git(['remote', 'set-url', 'origin', remote], cacheDir)
+ git(['fetch', '--prune', 'origin'], cacheDir)
+ git(['checkout', 'main'], cacheDir)
+ git(['reset', '--hard', 'origin/main'], cacheDir)
+ git(['clean', '-fd'], cacheDir)
+ } catch (err) {
+ fail(
+ `cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" — ${/** @type {Error} */ (err).message}\n` +
+ ` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`,
+ )
+ }
+ return
+ }
+
+ mkdirSync(dirname(cacheDir), { recursive: true })
+ try {
+ git(['clone', remote, cacheDir], dirname(cacheDir))
+ } catch (err) {
+ fail(
+ `cannot clone "${remote}" — ${/** @type {Error} */ (err).message}\n` +
+ ` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`,
+ )
+ }
+ try {
+ git(['checkout', 'main'], cacheDir)
+ } catch (err) {
+ fail(
+ `cloned "${remote}" into "${cacheDir}" but could not check out "main" — ${/** @type {Error} */ (err).message}\n` +
+ ` cure: confirm the releases repo's default branch is named "main"`,
+ )
+ }
+}
+
+/**
+ * Prepend `entry` to the wall at `/.json`, replacing any
+ * existing entry for the same version (idempotent re-runs), validating
+ * before and after, committing, and pushing — or refusing loudly, naming
+ * the cure, at whichever step fails.
+ * @param {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} entry
+ * @param {string} product
+ * @param {string} remote
+ * @param {string} cacheDir
+ */
+function publishEntry(entry, product, remote, cacheDir) {
+ ensureReleasesClone(remote, cacheDir)
+
+ const filePath = join(cacheDir, `${product}.json`)
+ if (!existsSync(filePath)) {
+ fail(
+ `"${filePath}" does not exist in the releases repo — cure: seed "${product}.json" at the repo root first (it must exist before any release rail can prepend to it)`,
+ )
+ }
+ const wall = loadWallFile(filePath)
+
+ if (wall.product !== product) {
+ fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given — refusing a cross-product write`)
+ }
+
+ const replacing = wall.entries.some((e) => e.version === entry.version)
+ wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)]
+
+ const postErrors = validateShape(wall)
+ if (postErrors.length) {
+ fail(`the entry for ${entry.version} would leave "${filePath}" invalid —\n ${postErrors.join('\n ')}`)
+ }
+
+ writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8')
+
+ const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir)
+ if (status === '') {
+ console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} — nothing to commit or push`)
+ return
+ }
+
+ const identity = resolveWallCommitIdentity()
+
+ try {
+ git(['add', `${product}.json`], cacheDir)
+ git(
+ ['-c', `user.name=${identity.name}`, '-c', `user.email=${identity.email}`, 'commit', '-m', `chore(wall): ${product} ${entry.version}`],
+ cacheDir,
+ )
+ } catch (err) {
+ fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`)
+ }
+
+ try {
+ git(['push', 'origin', 'main'], cacheDir)
+ } catch (err) {
+ fail(
+ `push to "${remote}" failed (likely a non-fast-forward — another release landed on main first) — ${/** @type {Error} */ (err).message}\n` +
+ ` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`,
+ )
+ }
+
+ const sha = git(['rev-parse', 'HEAD'], cacheDir)
+ console.log(
+ `wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) — pushed ${sha} to ${remote} main`,
+ )
+}
+
+function main() {
+ const args = parseArgs(process.argv.slice(2))
+
+ if (args.check) {
+ const filePath = /** @type {string | undefined} */ (args.file)
+ if (!filePath) fail('--check needs --file ')
+ const wall = loadWallFile(/** @type {string} */ (filePath))
+ console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`)
+ process.exit(0)
+ }
+
+ // Generate mode (default, also covers --dry-run): --product, --version,
+ // --date, --from-changelog required.
+ const product = /** @type {string | undefined} */ (args.product)
+ const version = /** @type {string | undefined} */ (args.version)
+ const date = /** @type {string | undefined} */ (args.date)
+ const fromChangelog = /** @type {string | undefined} */ (args['from-changelog'])
+
+ const missing = []
+ if (!product) missing.push('--product')
+ if (!version) missing.push('--version')
+ if (!date) missing.push('--date')
+ if (!fromChangelog) missing.push('--from-changelog')
+ if (missing.length) {
+ fail(
+ `missing required flag(s): ${missing.join(', ')}\n` +
+ 'Usage:\n' +
+ ' wall-entry.mjs --product --version --date --from-changelog [--dry-run]\n' +
+ ' wall-entry.mjs --check --file ',
+ )
+ }
+
+ const urlArg = args.url === true ? undefined : /** @type {string | undefined} */ (args.url)
+ const thumbArg = args.thumb === true ? undefined : /** @type {string | undefined} */ (args.thumb)
+
+ const entry = deriveEntry({
+ product: /** @type {string} */ (product),
+ version: /** @type {string} */ (version),
+ date: /** @type {string} */ (date),
+ changelogPath: /** @type {string} */ (fromChangelog),
+ url: urlArg,
+ thumb: thumbArg,
+ })
+
+ const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE)
+ const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir())
+
+ if (args['dry-run']) {
+ console.log(`wall-entry --dry-run: would write to "${join(cacheDir, `${product}.json`)}" in ${remote} (main), pushed as "chore(wall): ${product} ${version}"`)
+ console.log(JSON.stringify(entry, null, 2))
+ process.exit(0)
+ }
+
+ publishEntry(entry, /** @type {string} */ (product), remote, cacheDir)
+}
+
+main()
diff --git a/src/brainy.ts b/src/brainy.ts
index ffc2d5cd..fc08f291 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -531,6 +531,36 @@ export class Brainy implements BrainyInterface {
private static sigintListener?: () => void
private static beforeExitListener?: () => void
+ /** True while the `beforeExit` pass is running its flushes. Node re-emits
+ * 'beforeExit' after every loop drain and that pass schedules async work, so
+ * a second emit can arrive on top of the first; it returns instead of
+ * stacking a parallel pass. NOT a one-shot: every genuine drain still gets a
+ * flush. See {@link registerShutdownHooks}. */
+ private static beforeExitFlushInFlight = false
+
+ /** Whether the drained-event-loop notice has been printed for this
+ * registration cycle. Printed ONCE — `console.log` to a pipe is itself
+ * event-loop work, so narrating on every emit would keep the loop turning
+ * and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */
+ private static beforeExitNarrated = false
+
+ /** True for the entire duration of ONE `closeOnShutdown()` run (the
+ * signal-path handler in {@link registerShutdownHooks}) — from before it
+ * starts closing instances until after it has decided whether to exit.
+ * THE RACE THIS CLOSES: closing the LAST live instance calls
+ * `close()` → `deregisterShutdownHooksIfIdle()` synchronously, which
+ * removes `Brainy.sigtermListener` from `process` — while `closeOnShutdown`
+ * (that very listener's OWN still-running invocation) hasn't yet reached
+ * `exitIfSoleShutdownOwner()`'s `process.exit(0)`. In that window Node has
+ * NO registered SIGTERM listener, so a second/concurrent delivery of the
+ * same signal (a raced re-send, common on a loaded host) falls through to
+ * Node's default disposition and kills the process outright — the
+ * clean-shutdown work already finished, but the process never reports the
+ * 0 it earned. `deregisterShutdownHooksIfIdle()` checks this flag and
+ * defers; `closeOnShutdown()`'s `finally` re-runs the deregistration check
+ * once it is done, so the listener never actually leaks past its use. */
+ private static shutdownSignalHandlerActive = false
+
/** Poll cadence (ms) for the migration LOCK when a provider exposes no
* event-driven `whenMigrationComplete()` signal. See {@link awaitMigrationLock}. */
private static readonly MIGRATION_POLL_INTERVAL_MS = 250
@@ -767,6 +797,50 @@ export class Brainy implements BrainyInterface {
private _persistIdleTimer: ReturnType | null = null
private _persistBackgroundFlight: Promise | null = null
+ /**
+ * FLUSH IS SINGLE-FLIGHT, AND THE QUEUE IS ONE DEEP. `_flushInFlight` is the
+ * flush body actually running; `_flushFollowUp` is the AT MOST ONE flush
+ * queued behind it. Every caller — the write cadence, the cross-process
+ * flush-request watcher, an application calling `flush()` directly — either
+ * runs (nothing in flight), or joins the single queued follow-up.
+ *
+ * WHY A FOLLOW-UP RATHER THAN JOINING THE RUNNING FLUSH: a caller flushes to
+ * make ITS writes durable, and those writes may have landed after the
+ * running flush read its state. Joining would return "flushed" over data
+ * that was never persisted. Chaining one follow-up costs nothing when there
+ * is nothing new (a clean brain's flush returns immediately — see
+ * `_dirtySinceLastFlush`) and is correct when there is.
+ *
+ * MEASURED, in the production shutdown this was written for: two
+ * "Flushing Brainy indexes and caches to disk..." runs overlapping 3s
+ * apart on one brain, their walls growing 295ms → 4.9s as they contended
+ * for the same providers.
+ *
+ * THE WAITER IS SETTLED BY THE MACHINE, NEVER BY A PROMISE CHAIN. The queue
+ * is a BARE DEFERRED (`_flushQueued` plus its `_flushQueuedSettle` handles),
+ * not `leader.then(() => this.flush())`. A chained follow-up is settled only
+ * by resolving the very promise the leader is being awaited through, so the
+ * moment anything inside a flush body awaits `flush()` the graph closes on
+ * itself and NOBODY resolves — an unbounded hang, not a slow flush. Here the
+ * leader never awaits the queue: its `finally` PROMOTES the waiter to a new
+ * leader and settles the deferred from that run, and the leader's own
+ * promise settles without waiting for it. Every exit — the leader
+ * resolving, the leader REJECTING, the promoted run rejecting — runs the
+ * same promotion, so a queued caller is always settled exactly once.
+ */
+ private _flushInFlight: Promise | null = null
+ private _flushQueued: Promise | null = null
+ private _flushQueuedSettle: {
+ resolve: () => void
+ reject: (error: unknown) => void
+ } | null = null
+ /** Flush bodies that got past the single-flight gate (pinned by tests). */
+ private _flushBodyRuns = 0
+ /** Flush bodies running right now, and the high-water mark — which the
+ * single-flight law requires to stay at 1 (pinned by tests). */
+ private _flushBodiesActive = 0
+ private _flushConcurrencyPeak = 0
+
// DEFERRED EMBEDDING (MT5): pending markers are LOG RECORDS — an
// embed.pending record rides the deferred write's own commit fact and
// embed.landed rides the landing commit; this set is the in-memory
@@ -776,6 +850,50 @@ export class Brainy implements BrainyInterface {
private _pendingEmbedIds = new Set()
private _embedWorkerFlight: Promise | null = null
+ /**
+ * Ids cleared from {@link _pendingEmbedIds} with NO durable disarming record
+ * behind them — today exactly one case: a pending row that still EXISTS but
+ * carries no embeddable data, which the worker reaps in memory only. The log
+ * still says those ids are pending, so the pending-embed CHECKPOINT must
+ * carry them: the checkpoint's contract is "as of generation G the LOG's
+ * pending set was exactly this list", and a checkpoint that quietly dropped
+ * an id the log still arms would make the bounded fold disagree with a full
+ * fold from generation 1 — the one divergence that could lose a vector.
+ * Bounded by the number of such rows; an id leaves when it is re-enqueued or
+ * durably disarmed.
+ */
+ private _pendingEmbedUndurableClears = new Set()
+
+ /**
+ * Pending-set transitions (enqueue/clear) since the last checkpoint attempt —
+ * the checkpoint CADENCE. One mechanism, one hardcoded default, no knob and
+ * no timer (nothing to leave running after close).
+ */
+ private _pendingEmbedCheckpointTransitions = 0
+
+ /**
+ * A checkpoint is OWED: the cadence came due (or the set drained) and no
+ * write has satisfied it yet. It stays armed across attempts the durability
+ * law refuses, so the next transition that CAN be checkpointed is.
+ */
+ private _pendingEmbedCheckpointDue = false
+
+ /** Single-flight guard for the fire-and-forget checkpoint write. */
+ private _pendingEmbedCheckpointFlight: Promise | null = null
+
+ /**
+ * What the last pending-embed recovery fold actually did — the bound it
+ * used, where it started, and how many facts it read. The narration's
+ * source, and the accounting a pin reads instead of a clock.
+ */
+ private _pendingEmbedFoldReport: {
+ bound: 'checkpoint' | 'low-water' | 'genesis'
+ fromGeneration: number
+ factsScanned: number
+ seeded: number
+ pending: number
+ } | null = null
+
// OPEN-PATH FIX: the background embedding-engine warm kicked off (never
// awaited) by `performInit()` when `eagerEmbeddings` resolves true. Stored
// for observability only — `embed()`/`embeddingManager.embed()` already
@@ -845,6 +963,24 @@ export class Brainy implements BrainyInterface {
// applies only to instances that were never closed.
private closed = false
+ /**
+ * THE ONE CLOSE. Set SYNCHRONOUSLY by the first `close()` call, before that
+ * call yields, and never cleared — close is terminal. Every later or
+ * concurrent caller receives this same promise, so a shutdown with two
+ * callers (a host's pool close and the engine's own signal handler) runs
+ * ONE teardown, not two.
+ *
+ * MEASURED, the day this was added: a host that owns shutdown called
+ * `close()` on every pooled store at SIGTERM while the engine's signal
+ * handler flushed the same instances in parallel and released their writer
+ * locks in its own `finally`. One store took 149s to close (148s of it
+ * silent) against 24s for its idle siblings, and the same race in a local
+ * reproduction printed `Writer fence lost … the lock file is gone` — the
+ * handler observing a lock the close it was racing had already released.
+ * Two owners of one shutdown; now there is one, whoever calls first.
+ */
+ private _closeInFlight: Promise | null = null
+
// Index-build-at-open state. `lazyRebuildCompleted` predates the health-gate
// law (it named a first-QUERY lazy rebuild) and stays for `getIndexStatus()`
// API compatibility, but its truth changed: a needed rebuild now runs
@@ -2024,120 +2160,228 @@ export class Brainy implements BrainyInterface {
* Critical for Cloud Run, Fargate, Lambda, and other containerized deployments.
*
* Handles:
- * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda)
- * - SIGINT: Ctrl+C (development/local testing)
- * - beforeExit: Node.js cleanup hook (fallback)
+ * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda) — CLOSES.
+ * - SIGINT: Ctrl+C (development/local testing) — CLOSES.
+ * - beforeExit: the event loop drained — FLUSHES, and closes NOTHING. A
+ * drained loop is not a shutdown; see {@link flushOnDrainedEventLoop}'s
+ * contract below.
*
* NOTE: Registers globally (once for all instances) to avoid MaxListenersExceededWarning
*/
private registerShutdownHooks(): void {
/**
- * The signal-path shutdown. THREE LAWS, each written by a production
- * shutdown that looked clean and wasn't:
+ * The signal-path shutdown. ONE OWNER PER BRAIN, AND THE PATH IS `close()`.
*
- * 1. PER-INSTANCE ISOLATION. This used to be one `try` around a loop over
- * every open brain: the first instance whose flush rejected aborted the
- * loop, so every remaining brain kept its writer lock and its unwritten
- * markers — and the process still exited 0. A pool of brains failed in
- * a batch, not one at a time.
- * 2. THE MARKER IS PART OF SHUTDOWN. Flushing the indexes without closing
- * the generation store leaves the clean-shutdown marker unwritten, so
- * the NEXT open reads the store as crashed and folds the whole
- * generation log — measured in tens of seconds on a real store, paid on
- * every restart, after a shutdown the operator saw exit 0.
- * 3. THE LOCK IS ALWAYS GIVEN UP. In a `finally`, per instance: a process
- * on its way out holds nothing.
+ * WHAT THIS REPLACED, and why. The handler used to run its own shutdown —
+ * a parallel per-component flush, the generation store's close, a second
+ * parallel round of component closes, and a `finally` that stopped the
+ * flush-request watcher and released the writer lock. That is a SECOND
+ * teardown of the same brain, and a host application with its own SIGTERM
+ * handler (the shape every pooled deployment has) ran the FIRST one at the
+ * same moment. MEASURED in production the day this changed: a host closing
+ * seven pooled stores at SIGTERM printed "Shutdown signal received -
+ * flushing pending data...", went silent for 148s, printed "Flushed
+ * successfully (1 instance)", and the host's own close of that same store
+ * returned 1s later — 149s, against 24s for the six stores with no engine
+ * work in flight. The same race reproduced locally as
+ * `Failed to flush one Brainy instance on shutdown: Writer fence lost …
+ * the lock file is gone`: this handler observing a lock that the close it
+ * was racing had already released.
+ *
+ * SO: defer one macrotask, then per instance either STEP ASIDE (a close
+ * has begun or finished — its owner owns the flush, the markers and the
+ * lock) or `await instance.close()` — the one durable path, identical to
+ * what any caller gets. The three laws the old block carried are all
+ * satisfied by `close()`, each verified against its code:
+ *
+ * 1. PER-INSTANCE ISOLATION — kept HERE, in the per-instance try/catch
+ * below: one brain's failed close never aborts the loop over the rest.
+ * (`close()` itself is per-instance by construction.)
+ * 2. THE MARKER IS PART OF SHUTDOWN — `close()` → `closeDurableSteps()`
+ * Phase 1 awaits `this.generationStore.close()`, which persists the
+ * counter, advances the fold checkpoint and stamps the clean-shutdown
+ * marker LAST. That is the step that decides adopt-vs-fold at the next
+ * open, and it is the same call the old block made.
+ * 3. THE LOCK IS ALWAYS GIVEN UP — `close()`'s terminal releases run
+ * whether the durable steps threw or not (its contract: "TWO PARTS, AND
+ * THE SECOND IS UNCONDITIONAL"): `stopFlushRequestWatcher()` then
+ * `releaseWriterLock()`, then the VFS shutdown and the terminal
+ * `closed` flag, and only then is the original failure rethrown.
+ * `close()` releases the lock in MORE cases than the old block did — it
+ * also drains the metadata write buffer first, so no pending write can
+ * land after a successor writer claims the lock.
*/
- const flushOnShutdown = async () => {
+ const closeOnShutdown = async () => {
console.log('Shutdown signal received - flushing pending data...')
- let flushedCount = 0
- let failedCount = 0
- // Snapshot: close() splices Brainy.instances while we iterate.
- for (const instance of [...Brainy.instances]) {
- if (!instance.initialized) continue
- try {
- // Flush all buffered data (parallel across components, this brain only).
- await Promise.all([
- (async () => {
- if (instance.storage && typeof instance.storage.flushCounts === 'function') {
- await instance.storage.flushCounts()
- }
- })(),
- (async () => {
- if (instance.metadataIndex && typeof instance.metadataIndex.flush === 'function') {
- await instance.metadataIndex.flush()
- }
- })(),
- (async () => {
- if (instance.graphIndex && typeof instance.graphIndex.flush === 'function') {
- await instance.graphIndex.flush()
- }
- })(),
- (async () => {
- if (instance.index && typeof instance.index.flush === 'function') {
- await instance.index.flush()
- }
- })()
- ])
+ // HOLD THE LISTENER FOR THE WHOLE RUN. Closing the LAST live instance
+ // below calls close() → deregisterShutdownHooksIfIdle(), which removes
+ // Brainy's own SIGTERM/SIGINT listeners from `process` — synchronously,
+ // before THIS invocation has reached exitIfSoleShutdownOwner()'s
+ // process.exit(0). Left alone, that opens a window with no registered
+ // listener for the signal at all, so a second/concurrent delivery of
+ // the same signal (a raced re-send — not rare on a loaded host) falls
+ // through to Node's default disposition and kills the process outright
+ // AFTER the clean-shutdown work already finished, reporting a signal
+ // kill instead of the 0 the shutdown earned. Setting this flag makes
+ // deregisterShutdownHooksIfIdle() defer; the `finally` below re-checks
+ // it once this run is fully done — closeOnShutdown, not a nested
+ // close(), owns exactly when the listener actually comes off.
+ Brainy.shutdownSignalHandlerActive = true
+ try {
+ // DEFER ONE MACROTASK. A host application registers its own listener
+ // on the same signal, and Node runs listeners in registration order —
+ // ours is usually first, because the brain was opened before the
+ // host wired its shutdown. Yielding once lets every other listener
+ // for this signal run its synchronous prologue, so a host that calls
+ // close() gets to be the owner. It is only a courtesy, never the
+ // safety: close()'s own single-flight gate is what makes a lost race
+ // harmless.
+ await new Promise((resolve) => setImmediate(resolve))
- // Close the generation store: persists the counter, advances the
- // fold checkpoint, and stamps the clean-shutdown marker LAST — the
- // one step that decides whether the next open adopts or folds. Law 2.
- if (instance.generationStore && !instance.isReadOnly) {
- await instance.generationStore.close()
- }
-
- // Close components to stop timers that would prevent clean process exit
- await Promise.all([
- (async () => {
- if (instance.graphIndex && typeof instance.graphIndex.close === 'function') {
- await instance.graphIndex.close()
- }
- })(),
- (async () => {
- const index = instance.index as JsHnswVectorIndex & VectorIndexOptionalHooks
- if (index && typeof index.close === 'function') {
- await index.close()
- }
- })(),
- (async () => {
- const metadataIndex = instance.metadataIndex as MetadataIndexManager & MetadataIndexOptionalHooks
- if (metadataIndex && typeof metadataIndex.close === 'function') {
- await metadataIndex.close()
- }
- })()
- ])
- flushedCount++
- } catch (error) {
- failedCount++
- console.error('Failed to flush one Brainy instance on shutdown:', error)
- } finally {
- // Law 3 — the lock and the watcher go regardless.
- try {
- if (instance.storage && typeof instance.storage.stopFlushRequestWatcher === 'function') {
- instance.storage.stopFlushRequestWatcher()
- }
- } catch (error) {
- console.error('Failed to stop the flush-request watcher on shutdown:', error)
+ let closedCount = 0
+ let deferredCount = 0
+ let failedCount = 0
+ // Snapshot: close() splices Brainy.instances while we iterate.
+ for (const instance of [...Brainy.instances]) {
+ if (!instance.initialized) continue
+ // SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a
+ // component close — nothing. Touching a brain whose close is running
+ // is the whole defect this handler was rewritten for.
+ if (instance.closed || instance._closeInFlight !== null) {
+ deferredCount++
+ continue
}
try {
- if (instance.storage && typeof instance.storage.releaseWriterLock === 'function') {
- await instance.storage.releaseWriterLock()
- }
+ // Law 1: this try/catch is the isolation — the loop continues.
+ await instance.close()
+ closedCount++
} catch (error) {
- console.error('Failed to release the writer lock on shutdown:', error)
+ failedCount++
+ console.error('Failed to close one Brainy instance on shutdown:', error)
}
}
+ if (closedCount > 0) {
+ console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
+ }
+ if (deferredCount > 0) {
+ console.log(
+ `${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
+ `closing — left to the caller that owns that close.`
+ )
+ }
+ if (failedCount > 0) {
+ console.error(
+ `${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
+ `their writer locks were released, but their next open will run crash recovery.`
+ )
+ }
+ } finally {
+ // Release the hold and run the deferred check ourselves — the last
+ // close() above may have found the flag set and skipped its own
+ // deregistration, so nobody else will do this if we don't.
+ Brainy.shutdownSignalHandlerActive = false
+ Brainy.deregisterShutdownHooksIfIdle()
}
- if (flushedCount > 0) {
- console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 1 ? 's' : ''})`)
- }
- if (failedCount > 0) {
- console.error(
- `${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
- `their writer locks were released, but their next open will run crash recovery.`
+ }
+
+ /**
+ * THE DRAINED-EVENT-LOOP PATH. A DRAINED LOOP IS NOT A SHUTDOWN.
+ *
+ * Node emits `'beforeExit'` whenever the event loop has no REF'd work
+ * left — NOT when the process is ending, and with no signal involved. A
+ * perfectly healthy script reaches that state routinely: this engine
+ * unref's its idle and cadence timers ("an idle brain costs nothing"), so
+ * a script awaiting anything those timers drive is, for that instant,
+ * a process with no ref'd work and an open brain.
+ *
+ * MEASURED on the 11.1 rehearsal lane against a copy of a real store: the
+ * `beforeExit` listener was wired to the SIGNAL path, so after the heal
+ * phase the log printed `Shutdown signal received - flushing pending
+ * data...` and `Flushed successfully (1 instance)` with NO signal ever
+ * sent, and the script's very next `add()` threw `Brainy instance is not
+ * initialized: it was closed via close(). Create a new instance.` The
+ * engine had closed a live brain out from under a running script.
+ *
+ * SO, THE LAW: this path NEVER closes, deregisters, tears down or
+ * force-exits anything, and never releases a writer lock. It runs
+ * `flush()` — the engine's own non-closing durability door — on each live
+ * brain, and leaves every one of them open and usable.
+ *
+ * WHY flush() AND NOT NOTHING. Each claim checked against the code it
+ * names:
+ * 1. IT CANNOT CLOSE ANYTHING. `flush()` → `_flushSteps()` persists
+ * DERIVED state only: the count ledger, the metadata/graph/vector
+ * projections, the generation counter, aggregation state, the
+ * entity-tree stamp. It closes no component, deactivates no plugin,
+ * touches neither `initialized` nor `closed`, and never calls
+ * `releaseWriterLock()` — the clean-shutdown marker is written by
+ * `generationStore.close()` alone, reached only from `close()`.
+ * 2. IT CANNOT RACE A LATER WRITE INTO CORRUPTION. A background flush
+ * concurrent with live writes is the engine's ORDINARY steady state:
+ * `noteWriteForPersistence()` kicks exactly this call off an unref'd
+ * timer on every busy brain. `flush()` is single-flight with one queued
+ * follow-up, and a write landing mid-flush re-sets the dirty witness,
+ * so its work is never lost — it belongs to the next flush.
+ * 3. IT CANNOT SPIN. `flush()` on a clean brain returns without touching a
+ * provider or scheduling I/O, so the second emit does no event-loop
+ * work and the process exits. That is also why the listener is NOT
+ * self-deregistered any more: a one-shot listener spent on a spurious
+ * mid-script drain leaves the genuine end-of-script drain with nothing.
+ * 4. A FAILED FLUSH IS SURVIVABLE AND LOUD. The write path is durable at
+ * ack via the fact log; derived state is rebuildable. A throw is
+ * reported per instance and the loop continues — exactly how
+ * `kickBackgroundFlush()` already treats the same failure.
+ *
+ * The one thing lost against a closing handler is the clean-shutdown
+ * marker for a script that opens a brain and never closes it: its next
+ * open folds the log. That is the correct trade — a missing marker costs
+ * a recovery fold, closing a live brain costs the caller its brain — and
+ * the narration below names the cure.
+ */
+ const flushOnDrainedEventLoop = async () => {
+ // A second emit can land on top of the first (this pass schedules async
+ // work, the loop turns, the loop drains again). One pass at a time.
+ if (Brainy.beforeExitFlushInFlight) return
+
+ // Step aside for anyone whose close is running or done — the same
+ // ownership rule the signal path follows.
+ const live = [...Brainy.instances].filter(
+ (instance) => instance.initialized && !instance.closed && instance._closeInFlight === null
+ )
+ if (live.length === 0) return
+
+ // ONCE per registration cycle: a `console.log` to a pipe is itself
+ // event-loop work, so narrating on every emit would keep the loop
+ // turning and narrate forever.
+ if (!Brainy.beforeExitNarrated) {
+ Brainy.beforeExitNarrated = true
+ console.log(
+ `[Brainy] event loop drained with ${live.length} brain${live.length > 1 ? 's' : ''} ` +
+ `open — persisting derived state; NOTHING was closed. A drained loop is not a ` +
+ `shutdown: call close() (or send SIGTERM) when you mean one.`
)
}
+
+ Brainy.beforeExitFlushInFlight = true
+ try {
+ for (const instance of live) {
+ try {
+ await instance.flush()
+ } catch (error) {
+ // Per-instance isolation, and never fatal: canonical data is
+ // durable at ack, so a failed derived-state flush costs the next
+ // open a rebuild — it must not cost this one its brain.
+ console.error(
+ '[Brainy] flush on a drained event loop failed for one open brain ' +
+ '(the brain stays open and usable; derived-state persistence retries at the ' +
+ 'next flush, and canonical data is unaffected):',
+ error
+ )
+ }
+ }
+ } finally {
+ Brainy.beforeExitFlushInFlight = false
+ }
}
// Graceful shutdown signals (registered once globally). The listeners are
@@ -2157,32 +2401,39 @@ export class Brainy implements BrainyInterface {
* markers unwritten. When the host has its own handler (listener count
* above our own), the host owns the exit; Brainy only makes its data
* durable and steps aside.
+ *
+ * THE COUNT IS TAKEN WHEN THE SIGNAL ARRIVES, not after the shutdown ran.
+ * "Is anyone else handling this signal?" is a question about the moment
+ * the signal landed. Asking afterwards reads a process that has already
+ * torn itself down: the handler now CLOSES its instances, and closing the
+ * last brain deregisters Brainy's own listeners — so a host application's
+ * single remaining listener would look like `<= 1` and get force-exited
+ * out of its own graceful shutdown, precisely the failure above.
+ *
+ * SIGNALS ONLY — NEVER `beforeExit`. The reasoning above is entirely about
+ * a signal Brainy has suppressed Node's default terminate behaviour for.
+ * `beforeExit` suppresses nothing: Node exits by itself once the loop is
+ * genuinely done, and the script that is still running when it fires is
+ * not shutting down at all. Calling this from that path would end a live
+ * script at exit code 0 mid-work. It is called from the two signal
+ * listeners below and from nowhere else.
*/
- const exitIfSoleShutdownOwner = (signal: 'SIGTERM' | 'SIGINT'): void => {
- if (process.listenerCount(signal) <= 1) {
+ const exitIfSoleShutdownOwner = (ownersWhenSignalled: number): void => {
+ if (ownersWhenSignalled <= 1) {
process.exit(0)
}
}
Brainy.sigtermListener = async () => {
- await flushOnShutdown()
- exitIfSoleShutdownOwner('SIGTERM')
+ const owners = process.listenerCount('SIGTERM')
+ await closeOnShutdown()
+ exitIfSoleShutdownOwner(owners)
}
Brainy.sigintListener = async () => {
- await flushOnShutdown()
- exitIfSoleShutdownOwner('SIGINT')
- }
- Brainy.beforeExitListener = async () => {
- // Self-deregister FIRST: Node re-emits 'beforeExit' after every event-
- // loop drain, and this flush schedules new async work — with the
- // listener still attached, a script that never calls close() would spin
- // flush → drain → flush forever and never exit. One flush, then the
- // next drain finds no listener and the process exits.
- if (Brainy.beforeExitListener) {
- process.off('beforeExit', Brainy.beforeExitListener)
- Brainy.beforeExitListener = undefined
- }
- await flushOnShutdown()
+ const owners = process.listenerCount('SIGINT')
+ await closeOnShutdown()
+ exitIfSoleShutdownOwner(owners)
}
+ Brainy.beforeExitListener = flushOnDrainedEventLoop
process.on('SIGTERM', Brainy.sigtermListener)
process.on('SIGINT', Brainy.sigintListener)
process.on('beforeExit', Brainy.beforeExitListener)
@@ -2193,9 +2444,17 @@ export class Brainy implements BrainyInterface {
* script that closed every brain exits on its own — a library must never
* keep its host process alive. Re-initializing later re-registers them
* (the `shutdownHooksRegisteredGlobally` flag resets here).
+ *
+ * Deferred (not skipped — {@link closeOnShutdown}'s `finally` always
+ * re-checks) while a signal-path shutdown is actively running: that
+ * handler's OWN still-in-flight invocation is `Brainy.sigtermListener`, and
+ * removing it out from under itself — which closing the LAST instance here
+ * would otherwise do, synchronously, mid-run — would leave `process` with
+ * no listener for the signal for the remainder of that run. See
+ * {@link shutdownSignalHandlerActive}'s doc for the exact race this closes.
*/
private static deregisterShutdownHooksIfIdle(): void {
- if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally) {
+ if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally || Brainy.shutdownSignalHandlerActive) {
return
}
if (Brainy.sigtermListener) process.off('SIGTERM', Brainy.sigtermListener)
@@ -2204,6 +2463,11 @@ export class Brainy implements BrainyInterface {
Brainy.sigtermListener = undefined
Brainy.sigintListener = undefined
Brainy.beforeExitListener = undefined
+ // A later re-init is a fresh cycle: it may narrate its own drained-loop
+ // notice, and no pass of the previous cycle can still be running (the last
+ // close() drained the flush chain).
+ Brainy.beforeExitNarrated = false
+ Brainy.beforeExitFlushInFlight = false
Brainy.shutdownHooksRegisteredGlobally = false
}
@@ -2254,6 +2518,33 @@ export class Brainy implements BrainyInterface {
return this.initialized
}
+ /**
+ * @description Whether `close()` has BEGUN on this instance — in flight or
+ * already finished. The question a shutdown owner asks: this brain's
+ * teardown belongs to whoever started it, and a second party must not flush
+ * its components or release its writer lock underneath it.
+ *
+ * True from the synchronous moment `close()` is entered, so a listener that
+ * yields a tick and comes back reads the truth, not a stale "not yet".
+ * @returns `true` once a close has started.
+ */
+ get isClosing(): boolean {
+ return this._closeInFlight !== null
+ }
+
+ /**
+ * @description Whether `close()` has FINISHED tearing this instance down —
+ * durable steps attempted, writer lock released, instance terminal. A
+ * closed brain never re-initializes; every operation on it throws.
+ *
+ * True after a close that FAILED partway, too: such a brain still holds no
+ * writer lock and still serves nothing (see {@link close}).
+ * @returns `true` once the teardown has completed.
+ */
+ get isClosed(): boolean {
+ return this.closed
+ }
+
/**
* Promise that resolves when Brainy is fully initialized and ready to use
*
@@ -2434,6 +2725,47 @@ export class Brainy implements BrainyInterface {
*/
private static readonly PENDING_EMBED_LOWWATER_PATH = '_system/pending_embeds_lowwater.json'
+ /**
+ * Storage-root-relative path of the pending-embed CHECKPOINT:
+ * `{ generation, pending: string[], writtenAt }` — "as of durable generation
+ * G the pending set was exactly this list". Open seeds the set from `pending`
+ * and scans the log from `G + 1`, so the fold costs O(facts since G)
+ * REGARDLESS of whether the set ever drains.
+ *
+ * WHY IT REPLACES THE EMPTY-ONLY MARK AS THE BOUND. The low-water mark
+ * ({@link PENDING_EMBED_LOWWATER_PATH}) can only be written when the pending
+ * set is EMPTY, because it carries no set — it means "everything at or below
+ * G is consumed". A brain holding even ONE id that never lands (an embed that
+ * keeps failing; a row reaped in memory only and re-folded every open) never
+ * drains, so it never writes a mark, so the bound never engages on exactly
+ * the brains whose fold is expensive: every open re-reads the whole log. The
+ * checkpoint carries the set, so it needs no drain.
+ *
+ * The mark is still written and still read as the FALLBACK bound (a
+ * checkpoint that is absent, torn, or malformed degrades to it, and then to
+ * generation 1). Correctness over cost in every degradation: a stale or
+ * missing checkpoint only lengthens the scan.
+ */
+ private static readonly PENDING_EMBED_CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json'
+
+ /**
+ * Checkpoint CADENCE BASE: attempt a checkpoint every N pending-set
+ * transitions (enqueues + clears) while the brain is open, on top of the
+ * drain-to-empty and clean-close writes. Hardcoded 90th-percentile default,
+ * no knob, no timer: 64 transitions is far below the cost of the fold it
+ * bounds and far above the per-write noise floor. An attempt that cannot
+ * satisfy the durability law is SKIPPED, not forced — the next transition
+ * retries.
+ *
+ * The interval ADAPTS to the one signal that matters, the backlog's own
+ * size, because a checkpoint writes the WHOLE pending list: the interval is
+ * `max(64, ceil(|pending| / 64))`, which holds the amortized cost of the
+ * mechanism at ≤ 64 ids written per transition NO MATTER how large the
+ * backlog grows. A term that scales with the store rather than with the
+ * work is exactly the defect class this file is fixing; it must not be
+ * reintroduced by the cure.
+ */
+ private static readonly PENDING_EMBED_CHECKPOINT_EVERY = 64
/**
* @description Mark a deferred embed pending (MT5): the id joins the
@@ -2448,6 +2780,9 @@ export class Brainy implements BrainyInterface {
*/
private enqueuePendingEmbed(id: string): FactMarkerRecord {
this._pendingEmbedIds.add(id)
+ // Re-armed for real: any earlier in-memory-only clear is superseded.
+ this._pendingEmbedUndurableClears.delete(id)
+ this.noteEmbedCheckpointCadence()
return { type: 'embed.pending', id, enqueuedAt: Date.now() }
}
@@ -2458,11 +2793,27 @@ export class Brainy implements BrainyInterface {
* fact) — the recovery fold consumes those; nothing here touches storage.
* One honest residue: a pending row whose entity still exists but carries
* no data is reaped in memory only, so it re-folds at the next open and
- * is re-reaped there — a bounded no-op, never a lost vector.
+ * is re-reaped there — a bounded no-op, never a lost vector. That residue
+ * is the ONLY `durability: 'in-memory-only'` caller, and the checkpoint
+ * keeps carrying those ids so the bounded fold and a full fold from
+ * generation 1 agree exactly (see {@link _pendingEmbedUndurableClears}).
+ *
+ * @param id - The pending id to clear.
+ * @param durability - `'durable'` (default) when a record in the log at or
+ * below the current head disarms this id (an `embed.landed` riding the
+ * landing or unvector commit, or the row's tombstone — including the row
+ * simply not being there any more); `'in-memory-only'` when nothing in the
+ * log says so.
*/
- private clearPendingEmbed(id: string): void {
+ private clearPendingEmbed(
+ id: string,
+ durability: 'durable' | 'in-memory-only' = 'durable'
+ ): void {
this._pendingEmbedIds.delete(id)
+ if (durability === 'in-memory-only') this._pendingEmbedUndurableClears.add(id)
+ else this._pendingEmbedUndurableClears.delete(id)
if (this._pendingEmbedIds.size === 0) this.maybeWriteEmbedLowWater()
+ this.noteEmbedCheckpointCadence()
}
/**
@@ -2498,6 +2849,223 @@ export class Brainy implements BrainyInterface {
}
}
+ /**
+ * @description Capture a pending-embed checkpoint, or refuse.
+ *
+ * THE DURABILITY LAW, satisfied by construction. The checkpoint asserts "as
+ * of generation G the log's pending set was exactly this list", and the next
+ * open TRUSTS it: it seeds the set and never reads a fact at or below G
+ * again. So a checkpoint may only be taken at a G whose facts are DURABLE.
+ * A checkpoint taken at head H while the facts up to H are still buffered
+ * would be read back after a crash that truncated the tail — and an
+ * `embed.landed` in a truncated fact would be gone from the log while the
+ * checkpoint still recorded its id as landed. The row's landing vector went
+ * with the truncated fact, so nothing would ever re-arm it: A LOST VECTOR.
+ *
+ * The gate is therefore `0 < head ≤ committed`. `committed` is the
+ * generation manifest's watermark — the point the store's own recovery
+ * treats as truth, and the point below which `FactLog.open()` never
+ * truncates — and the group-commit flush fsyncs the log BEFORE advancing it
+ * (see `GenerationStore.flushPendingSingleOps`). So every fact at or below
+ * `head` is fsynced and survives the crash exactly as the checkpoint
+ * describes it. Anything else (a head above the manifest, no log, no
+ * generation yet, a read-only or closed brain) REFUSES: skipping a
+ * checkpoint costs a longer scan next open, never a marker.
+ *
+ * The snapshot is taken SYNCHRONOUSLY with reading the two generations — no
+ * `await` between them — so no commit and no worker step can slip between
+ * "the generation I am about to claim" and "the set I claim for it".
+ *
+ * The one asymmetry, deliberately in the safe direction: an id whose
+ * `embed.pending` record has not been appended yet (enqueued in memory, its
+ * commit still in flight) is captured as pending at G although its marker
+ * will land at G+1 or later. Over-stating pending costs one idempotent
+ * re-embed attempt; under-stating it is the shape that loses a vector, and
+ * cannot happen — every clear either rides a durable record at or below the
+ * head, or is carried in {@link _pendingEmbedUndurableClears}.
+ *
+ * @returns The checkpoint payload, or `null` when this instant cannot host
+ * one.
+ */
+ private captureEmbedCheckpoint(): { generation: number; pending: string[] } | null {
+ if (this.isReadOnly || this.closed) return null
+ const store = this.generationStore
+ if (!store) return null
+ const log = store.getFactLog()
+ if (!log) return null
+ // --- ONE SYNCHRONOUS INSTANT: no await until the return. ---
+ const generation = log.headGeneration()
+ const committed = store.committedGeneration()
+ if (!(generation > 0) || generation > committed) return null
+ const pending = new Set(this._pendingEmbedIds)
+ for (const id of this._pendingEmbedUndurableClears) pending.add(id)
+ // --- end of the synchronous instant. ---
+ return { generation, pending: [...pending] }
+ }
+
+ /**
+ * @description Fire-and-forget checkpoint write, single-flight: a burst of
+ * transitions never stacks writes, and because each attempt captures
+ * immediately before it writes, the file always ends up holding the most
+ * recently captured (generation, set) PAIR — and every such pair is
+ * independently true, so even an out-of-order landing is safe.
+ * {@link closeDurableSteps} awaits the flight before taking the final one.
+ */
+ private maybeWriteEmbedCheckpoint(): void {
+ if (this._pendingEmbedCheckpointFlight) return
+ this._pendingEmbedCheckpointFlight = this.writeEmbedCheckpoint()
+ .then((wrote) => {
+ if (wrote) {
+ this._pendingEmbedCheckpointDue = false
+ this._pendingEmbedCheckpointTransitions = 0
+ }
+ })
+ .finally(() => {
+ this._pendingEmbedCheckpointFlight = null
+ })
+ }
+
+ /**
+ * The awaitable core of {@link maybeWriteEmbedCheckpoint}.
+ * @returns `true` when a checkpoint was actually written.
+ */
+ private async writeEmbedCheckpoint(): Promise {
+ const snapshot = this.captureEmbedCheckpoint()
+ if (!snapshot) return false
+ try {
+ // Atomic on disk: the filesystem adapter's writeRawObject is tmp+rename
+ // (see BaseStorage.writeRawObject), so a crash mid-write leaves either
+ // the previous checkpoint or the new one — never a spliced file. And a
+ // file that IS unreadable (a torn gzip, invalid JSON) throws typed on
+ // read and degrades to the fallback bound; it can never parse into a
+ // partial `pending` list.
+ //
+ // The file is NOT separately fsynced, and does not need to be: losing
+ // the rename to a power cut leaves the PREVIOUS checkpoint (or none),
+ // which only lengthens the next scan. The invariant that matters is the
+ // other direction — a checkpoint that IS visible names a generation
+ // whose facts are durable — and that is established by the capture gate
+ // above, not by this write.
+ await this.storage.writeRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH, {
+ generation: snapshot.generation,
+ pending: snapshot.pending,
+ writtenAt: Date.now()
+ })
+ return true
+ } catch (err) {
+ prodLog.warn(
+ `[Brainy] pending-embed checkpoint write failed at generation ` +
+ `${snapshot.generation}: ${(err as Error).message} — the next open scans ` +
+ `from the previous checkpoint`
+ )
+ return false
+ }
+ }
+
+ /**
+ * @description The checkpoint cadence tick: count one pending-set transition
+ * and OWE a checkpoint every {@link PENDING_EMBED_CHECKPOINT_EVERY}
+ * transitions, plus on every drain to empty. The debt stays armed across
+ * attempts the durability law refuses — during a write burst the log head
+ * legitimately runs ahead of the manifest, so the first attempt often cannot
+ * be taken — and the next transition retries it. An active brain therefore
+ * checkpoints steadily without ever forcing a flush; an idle one relies on
+ * its clean close. No timer is involved, so nothing survives close().
+ */
+ private noteEmbedCheckpointCadence(): void {
+ if (this.isReadOnly || this.closed) return
+ this._pendingEmbedCheckpointTransitions++
+ const listed = this._pendingEmbedIds.size + this._pendingEmbedUndurableClears.size
+ const every = Math.max(
+ Brainy.PENDING_EMBED_CHECKPOINT_EVERY,
+ Math.ceil(listed / Brainy.PENDING_EMBED_CHECKPOINT_EVERY)
+ )
+ if (
+ this._pendingEmbedIds.size === 0 ||
+ this._pendingEmbedCheckpointTransitions >= every
+ ) {
+ this._pendingEmbedCheckpointDue = true
+ }
+ if (this._pendingEmbedCheckpointDue) this.maybeWriteEmbedCheckpoint()
+ }
+
+ /**
+ * @description Resolve the pending-embed fold's BOUND: the checkpoint first
+ * (a set plus a generation), then the legacy low-water mark (a generation
+ * only), then genesis. Every degradation is loud and lengthens the scan
+ * rather than shortening it — a bound that could skip a marker is never
+ * derived from a value this method could not fully validate.
+ * @returns The bound's name, the first generation to scan, and the ids to
+ * seed the pending set with.
+ */
+ private async readPendingEmbedBound(): Promise<{
+ bound: 'checkpoint' | 'low-water' | 'genesis'
+ fromGeneration: number
+ seeded: string[]
+ }> {
+ let checkpointRejected: string | null = null
+ try {
+ const raw = await this.storage.readRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH)
+ if (raw !== null && raw !== undefined) {
+ const parsed = Brainy.parsePendingEmbedCheckpoint(raw)
+ if (parsed) {
+ return {
+ bound: 'checkpoint',
+ fromGeneration: parsed.generation + 1,
+ seeded: parsed.pending
+ }
+ }
+ checkpointRejected = 'its shape is not { generation: number > 0, pending: string[] }'
+ }
+ } catch (err) {
+ // A real storage fault (EIO/EACCES/…). Corruption never lands here: the
+ // adapter maps a torn raw object to `null` AFTER logging it as a
+ // production error, so a torn checkpoint arrives as "absent" — loud at
+ // the adapter, and bounded here by the fallback below.
+ checkpointRejected = `reading it failed: ${(err as Error).message}`
+ }
+ if (checkpointRejected !== null) {
+ prodLog.warn(
+ `[Brainy] pending-embed checkpoint REFUSED (${checkpointRejected}) — falling back ` +
+ `to the low-water mark, else a full fold from generation 1`
+ )
+ }
+
+ try {
+ const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as {
+ generation?: number
+ } | null
+ if (mark && typeof mark.generation === 'number' && mark.generation > 0) {
+ return { bound: 'low-water', fromGeneration: mark.generation + 1, seeded: [] }
+ }
+ } catch {
+ // No mark (or unreadable): scan from 1 — correctness over cost.
+ }
+ return { bound: 'genesis', fromGeneration: 1, seeded: [] }
+ }
+
+ /**
+ * @description Validate a raw checkpoint object STRICTLY. Anything that is
+ * not exactly `{ generation: integer > 0, pending: string[] }` is refused
+ * whole — a partially-usable checkpoint is the one shape that could seed a
+ * short pending set behind a high bound, which is how a vector is lost.
+ * @param raw - The object read back from storage.
+ * @returns The validated checkpoint, or `null`.
+ */
+ private static parsePendingEmbedCheckpoint(
+ raw: unknown
+ ): { generation: number; pending: string[] } | null {
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
+ const { generation, pending } = raw as { generation?: unknown; pending?: unknown }
+ if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= 0) {
+ return null
+ }
+ if (!Array.isArray(pending) || pending.some((id) => typeof id !== 'string' || id === '')) {
+ return null
+ }
+ return { generation, pending: pending as string[] }
+ }
+
/**
* @description Rebuild the pending-embed set by REPLAYING the generation
* log's marker records (recovery = replay, not listing): `embed.pending`
@@ -2506,14 +3074,22 @@ export class Brainy implements BrainyInterface {
* survives the fold is exactly the set of acknowledged deferred writes
* whose vectors have not landed.
*
- * BOUND: the scan starts at the advisory low-water mark
- * ({@link Brainy.PENDING_EMBED_LOWWATER_PATH}) — the log head at which the
- * pending set last drained to empty — so a settled brain reads only the
- * facts since then, not its whole history. Without a mark (first open
- * after upgrade) it scans from generation 1, once; a stale-low mark costs
- * a longer scan, never a marker. The fold stays on the open's foreground —
- * the crash-recovery contract pins that a reopened brain has its markers
- * re-armed when open() returns — and the mark is what makes that cheap.
+ * BOUND: the scan starts after the pending-embed CHECKPOINT
+ * ({@link Brainy.PENDING_EMBED_CHECKPOINT_PATH}) — "as of durable generation
+ * G the pending set was exactly this list" — so the fold seeds the set from
+ * that list and reads only the facts after G. O(delta) whether or not the
+ * set ever drains, which is the whole point: the previous bound, the
+ * empty-only low-water mark, could not be written at all by a brain holding
+ * one id that never lands, so those brains re-read their whole log at every
+ * open. The mark remains the FALLBACK bound (checkpoint absent, torn, or
+ * malformed), and generation 1 the fallback below that — a brain opened for
+ * the first time after this change has neither a checkpoint nor, if it never
+ * drained, a mark, so it pays one full fold and writes a checkpoint on the
+ * way out. A stale bound costs a longer scan, never a marker. The fold stays
+ * on the open's foreground — the crash-recovery contract pins that a
+ * reopened brain has its markers re-armed when open() returns — and the
+ * bound is what makes that cheap. What it did (bound, start, facts read) is
+ * narrated and kept in {@link _pendingEmbedFoldReport}.
* It is SKIPPED WHOLESALE when the log has never had a v2 tail
* ({@link FactLog.hasV2History} — v1 facts cannot carry marker records),
* so pre-cutover brains pay nothing; on a mixed log the scan still reads
@@ -2526,20 +3102,13 @@ export class Brainy implements BrainyInterface {
private async recoverPendingEmbedsFromLog(): Promise {
const log = this.generationStore.getFactLog()
if (!log || !log.hasV2History()) return
- let fromGeneration = 1
- try {
- const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as {
- generation?: number
- } | null
- if (mark && typeof mark.generation === 'number' && mark.generation > 0) {
- fromGeneration = mark.generation + 1
- }
- } catch {
- // No mark (or unreadable): scan from 1 — correctness over cost.
- }
+ const { bound, fromGeneration, seeded } = await this.readPendingEmbedBound()
+ for (const id of seeded) this._pendingEmbedIds.add(id)
+ let factsScanned = 0
const scan = log.scanFacts({ fromGeneration })
for await (const batch of scan.batches()) {
for (const fact of batch.facts) {
+ factsScanned++
for (const record of fact.records ?? []) {
if (record.type === 'embed.pending') {
this._pendingEmbedIds.add(record.id)
@@ -2554,6 +3123,21 @@ export class Brainy implements BrainyInterface {
}
}
}
+ this._pendingEmbedFoldReport = {
+ bound,
+ fromGeneration,
+ factsScanned,
+ seeded: seeded.length,
+ pending: this._pendingEmbedIds.size
+ }
+ // The narration channel: an operator is entitled to hear which bound
+ // applied and what it cost, on every open — that is how a bound that
+ // silently stopped engaging (the defect this replaced) becomes visible.
+ prodLog.narrate(
+ `[Brainy] pending-embed fold: ${bound} bound → scanned ${factsScanned} fact(s) ` +
+ `from generation ${fromGeneration}, seeded ${seeded.length} id(s), ` +
+ `${this._pendingEmbedIds.size} pending`
+ )
}
/**
@@ -2645,11 +3229,23 @@ export class Brainy implements BrainyInterface {
for (const id of batch) {
try {
const entity = await this.get(id, { includeVectors: true })
- if (!entity || entity.data === undefined || entity.data === null) {
- // Orphan reap: a deleted row's tombstone fact durably disarms the
- // marker at the next recovery fold; a data-less-but-present row
- // (edge case) re-folds and re-reaps — bounded, never a lost vector.
- this.clearPendingEmbed(id)
+ if (!entity) {
+ // The row is GONE. Either it was deleted — its tombstone fact
+ // durably disarms the marker, at or below the head, exactly as the
+ // fold reads it — or its create never became durable, in which case
+ // the log carries no `embed.pending` for it either. Both are durable
+ // clears: a full fold from generation 1 reaches the same answer.
+ this.clearPendingEmbed(id, 'durable')
+ continue
+ }
+ if (entity.data === undefined || entity.data === null) {
+ // Orphan reap, IN MEMORY ONLY: a data-less-but-present row (edge
+ // case) has nothing to embed, but no record in the log says so, so
+ // the fold would re-arm it. Cleared here and carried in the
+ // checkpoint (see clearPendingEmbed) — it re-folds and re-reaps at
+ // the next open exactly as before: bounded, never a lost vector,
+ // and never a checkpoint that disagrees with the log.
+ this.clearPendingEmbed(id, 'in-memory-only')
continue
}
// Hang guard: a wedged embedder must not block every later pending
@@ -2922,9 +3518,18 @@ export class Brainy implements BrainyInterface {
* toward the next trigger. A failure is LOUD and leaves the writes counted
* again — silence is not an option, and neither is a retry storm (the next
* trigger re-attempts).
+ *
+ * COALESCING LIVES IN {@link flush}, NOT HERE. A kick that arrives while a
+ * flush is running used to return without doing anything — the writes it
+ * counted waited for some LATER trigger, and this method's guard also could
+ * not coalesce the flushes it does not start (the cross-process
+ * flush-request watcher and application `flush()` calls both go straight to
+ * `flush()`; two of those overlapping is exactly what production showed).
+ * The gate in `flush()` covers every caller: this kick now either runs the
+ * flush or joins the single queued follow-up, so the writes it counted are
+ * always someone's work, and there is still never a second concurrent run.
*/
private kickBackgroundFlush(reason: 'threshold' | 'idle'): void {
- if (this._persistBackgroundFlight) return
const counted = this._persistDirtyWrites
this._persistDirtyWrites = 0
this._persistLastFlushAt = Date.now()
@@ -3636,6 +4241,16 @@ export class Brainy implements BrainyInterface {
}
// Route to metadata-only or full entity based on options
+ // A PROJECTED get goes through the same seam every list page uses, so a
+ // detail read of two scalars costs an index read rather than a record read.
+ // It is checked before `includeVectors` because the two are incompatible by
+ // construction: a projection returns the named fields, and a vector is not
+ // one of them unless it was named.
+ if (options?.fields !== undefined && options.fields.length > 0) {
+ const page = await this.#hydratePage([id], options.fields)
+ return page.get(id) ?? null
+ }
+
const includeVectors = options?.includeVectors ?? false // Default: metadata-only (fast)
if (includeVectors) {
@@ -3682,6 +4297,170 @@ export class Brainy implements BrainyInterface {
* const children = childIds.map(id => childrenMap.get(id)).filter(Boolean)
* ```
*/
+ /**
+ * **The projection seam** — hydrate a page of ids under an optional `fields`
+ * projection, opening the canonical record only when the index cannot serve
+ * what was asked for.
+ *
+ * Without a projection this is exactly `batchGet`, byte for byte: the whole
+ * point is that `fields` absent changes nothing.
+ *
+ * With one, the order is: ask the index for the named scalars in a single
+ * batched door; see which requested fields it actually served; and read
+ * records ONLY if something is still missing — and only to fill those fields.
+ * A page whose every requested field is index-served performs zero canonical
+ * reads, which is the whole reason the door exists.
+ *
+ * `guardFields` are fetched ALONGSIDE the projection and trimmed off before
+ * the caller sees them. find()'s index-integrity guard re-validates every row
+ * against its own predicate, and it reads the entity to do so — so a row
+ * projected down to `title` would fail a `where: { kind }` it genuinely
+ * matches, and the whole page would vanish. The fields a filter names are
+ * fields the index can serve by definition, so carrying them costs nothing
+ * and keeps the guard honest.
+ *
+ * A field nothing can supply is simply absent from the row. That is the
+ * permissive law: a projection asks "these, if you have them", and an
+ * optional field must not turn a list into an exception. It deliberately does
+ * NOT route through the strict address resolver, which throws
+ * `UnresolvableFieldError` for an unknown key — that strictness is right for
+ * `orderBy`, where a typo silently changes the order, and wrong here, where
+ * the honest answer is "this row does not have that".
+ *
+ * @param ids - Canonical ids for the page.
+ * @param fields - The projection, or undefined for the full record.
+ * @returns `id → entity`, projected when `fields` was given.
+ */
+ /**
+ * The index keys find()'s integrity guard reads when it re-validates a row.
+ *
+ * The guard calls `entityMatchesFind(entity, params)`, so a projected entity
+ * must still carry whatever the params constrain — otherwise a row that
+ * genuinely matches is dropped for lacking the evidence. These are fetched
+ * with the projection and trimmed off before the caller sees them.
+ *
+ * @param params - The find params.
+ * @returns Index keys to carry through hydration.
+ */
+ #guardFieldsFor(params: FindParams): string[] {
+ const keys: string[] = []
+ if (params.where && typeof params.where === 'object') {
+ // Top-level where keys only: nested `anyOf`/`allOf` branches are carried
+ // by their own keys when the guard walks them, and a filter whose
+ // evidence is missing keeps the row (the guard's own catch) rather than
+ // dropping it.
+ for (const key of Object.keys(params.where as Record)) {
+ if (key === 'anyOf' || key === 'allOf' || key === 'not') continue
+ keys.push(key)
+ }
+ }
+ if (params.type !== undefined) keys.push('system.type')
+ if (params.subtype !== undefined) keys.push('system.subtype')
+ if (params.service !== undefined) keys.push('system.service')
+ if (params.excludeVFS === true) keys.push('vfsType', 'isVFSEntity')
+ return keys
+ }
+
+ async #hydratePage(
+ ids: string[],
+ fields?: readonly string[],
+ guardFields: readonly string[] = []
+ ): Promise