diff --git a/.forgejo/workflows/publish-source.yml b/.forgejo/workflows/publish-source.yml
index 6bd42b2a..58cb1d30 100644
--- a/.forgejo/workflows/publish-source.yml
+++ b/.forgejo/workflows/publish-source.yml
@@ -12,11 +12,6 @@ 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 c4f89332..62d81cfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,28 +2,6 @@
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)
diff --git a/README.md b/README.md
index fbf129ac..762c9ec3 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,6 @@
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 64e64873..e8833b80 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,15 +1,5 @@
# @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 6aa33515..77fbbd79 100644
--- a/docs/FIND_SYSTEM.md
+++ b/docs/FIND_SYSTEM.md
@@ -369,71 +369,6 @@ 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 c4f4e056..12cb37c8 100644
--- a/docs/api-contract.json
+++ b/docs/api-contract.json
@@ -1507,7 +1507,6 @@
"BrainyError",
"DerivedArtifactMissingError",
"GraphIndexNotReadyError",
- "MetadataArrayTooLargeError",
"MetadataIndexNotReadyError",
"MigrationInProgressError",
"ProtectedArtifactError",
diff --git a/docs/architecture/data-storage-architecture.md b/docs/architecture/data-storage-architecture.md
index 12398747..83b9e23a 100644
--- a/docs/architecture/data-storage-architecture.md
+++ b/docs/architecture/data-storage-architecture.md
@@ -217,40 +217,6 @@ 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 d698eee8..8fda315f 100644
--- a/docs/concepts/multi-process.md
+++ b/docs/concepts/multi-process.md
@@ -95,15 +95,8 @@ 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` 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`.
+hooks Brainy registers for `SIGTERM`, `SIGINT`, and `beforeExit` also
+release the lock so a container restart doesn't strand the directory.
## How to inspect a live writer
diff --git a/package-lock.json b/package-lock.json
index bd12e46c..3e3bf96d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@soulcraftlabs/brainy",
- "version": "10.4.13",
+ "version": "10.4.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@soulcraftlabs/brainy",
- "version": "10.4.13",
+ "version": "10.4.11",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
diff --git a/package.json b/package.json
index a3bd0483..8676f8b7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@soulcraftlabs/brainy",
- "version": "10.4.13",
+ "version": "10.4.11",
"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",
diff --git a/scripts/release.sh b/scripts/release.sh
index a9a1f6e9..1a4fe575 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -154,26 +154,13 @@ else
fi
# Create new changelog entry
-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})
+CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
${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 [ "$CURATED_ENTRY" = false ] && [ -f "CHANGELOG.md" ]; then
+if [ -f "CHANGELOG.md" ]; then
# Read header (first 4 lines)
HEADER=$(head -n 4 CHANGELOG.md)
# Read rest of file
@@ -187,19 +174,6 @@ if [ "$CURATED_ENTRY" = false ] && [ -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
@@ -263,7 +237,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/soulcraftlabs/open-brainy/releases" \
+ if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/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
deleted file mode 100644
index 5079cf86..00000000
--- a/scripts/wall-entry.mjs
+++ /dev/null
@@ -1,539 +0,0 @@
-#!/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 fc08f291..81250144 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -531,36 +531,6 @@ 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
@@ -2160,11 +2130,9 @@ export class Brainy implements BrainyInterface {
* Critical for Cloud Run, Fargate, Lambda, and other containerized deployments.
*
* Handles:
- * - 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.
+ * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda)
+ * - SIGINT: Ctrl+C (development/local testing)
+ * - beforeExit: Node.js cleanup hook (fallback)
*
* NOTE: Registers globally (once for all instances) to avoid MaxListenersExceededWarning
*/
@@ -2213,174 +2181,51 @@ export class Brainy implements BrainyInterface {
*/
const closeOnShutdown = async () => {
console.log('Shutdown signal received - flushing pending data...')
- // 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))
+ // 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))
- 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 {
- // Law 1: this try/catch is the isolation — the loop continues.
- await instance.close()
- closedCount++
- } catch (error) {
- failedCount++
- console.error('Failed to close one Brainy instance 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
}
- if (closedCount > 0) {
- console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
+ try {
+ // Law 1: this try/catch is the isolation — the loop continues.
+ await instance.close()
+ closedCount++
+ } catch (error) {
+ failedCount++
+ console.error('Failed to close one Brainy instance on shutdown:', error)
}
- 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()
}
- }
-
- /**
- * 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
+ if (closedCount > 0) {
+ console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
+ }
+ if (deferredCount > 0) {
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.`
+ `${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
+ `closing — left to the caller that owns that close.`
)
}
-
- 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
+ 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.`
+ )
}
}
@@ -2409,14 +2254,6 @@ export class Brainy implements BrainyInterface {
* 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 = (ownersWhenSignalled: number): void => {
if (ownersWhenSignalled <= 1) {
@@ -2433,7 +2270,18 @@ export class Brainy implements BrainyInterface {
await closeOnShutdown()
exitIfSoleShutdownOwner(owners)
}
- Brainy.beforeExitListener = flushOnDrainedEventLoop
+ 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 closeOnShutdown()
+ }
process.on('SIGTERM', Brainy.sigtermListener)
process.on('SIGINT', Brainy.sigintListener)
process.on('beforeExit', Brainy.beforeExitListener)
@@ -2444,17 +2292,9 @@ 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 || Brainy.shutdownSignalHandlerActive) {
+ if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally) {
return
}
if (Brainy.sigtermListener) process.off('SIGTERM', Brainy.sigtermListener)
@@ -2463,11 +2303,6 @@ 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
}
@@ -4241,16 +4076,6 @@ 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) {
@@ -4297,170 +4122,6 @@ 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