diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index da5887f6..5e93cd96 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -5,10 +5,6 @@ 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 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 fc577c1d..16fb5786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,50 +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.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 c58520b7..54d4f784 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,20 +41,6 @@ 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/RELEASES.md b/RELEASES.md index c875cb26..e8833b80 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,12 +1,5 @@ # @soulcraft/brainy — Release Notes for Consumers -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..aafd838a 100644 --- a/docs/api-contract.json +++ b/docs/api-contract.json @@ -161,11 +161,6 @@ "kind": "method", "arity": 1 }, - { - "name": "captureEmbedCheckpoint", - "kind": "method", - "arity": 0 - }, { "name": "checkHealth", "kind": "method", @@ -230,11 +225,6 @@ "name": "counts", "kind": "accessor" }, - { - "name": "createGenerationStore", - "kind": "method", - "arity": 1 - }, { "name": "createIndex", "kind": "method", @@ -268,11 +258,6 @@ "kind": "method", "arity": 1 }, - { - "name": "demoteTornEntityTreeStamp", - "kind": "method", - "arity": 4 - }, { "name": "detectIdKind", "kind": "method", @@ -368,6 +353,11 @@ "kind": "method", "arity": 1 }, + { + "name": "executeGraphSearch", + "kind": "method", + "arity": 2 + }, { "name": "executeProximitySearch", "kind": "method", @@ -378,21 +368,11 @@ "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", @@ -438,11 +418,6 @@ "kind": "method", "arity": 2 }, - { - "name": "filterIdsWithinBelted", - "kind": "method", - "arity": 2 - }, { "name": "find", "kind": "method", @@ -736,11 +711,6 @@ "kind": "method", "arity": 2 }, - { - "name": "hydrateResultPage", - "kind": "method", - "arity": 2 - }, { "name": "import", "kind": "method", @@ -771,14 +741,6 @@ "kind": "method", "arity": 0 }, - { - "name": "isClosed", - "kind": "accessor" - }, - { - "name": "isClosing", - "kind": "accessor" - }, { "name": "isEmbeddingReady", "kind": "method", @@ -837,16 +799,6 @@ "kind": "method", "arity": 1 }, - { - "name": "maybeWriteEmbedCheckpoint", - "kind": "method", - "arity": 0 - }, - { - "name": "maybeWriteEmbedLowWater", - "kind": "method", - "arity": 0 - }, { "name": "metadataIndexRetractionOp", "kind": "method", @@ -902,11 +854,6 @@ "kind": "method", "arity": 1 }, - { - "name": "noteEmbedCheckpointCadence", - "kind": "method", - "arity": 0 - }, { "name": "noteWriteForPersistence", "kind": "method", @@ -922,11 +869,6 @@ "kind": "method", "arity": 1 }, - { - "name": "pageConnectedIds", - "kind": "method", - "arity": 2 - }, { "name": "pagination", "kind": "accessor" @@ -951,11 +893,6 @@ "kind": "method", "arity": 0 }, - { - "name": "pendingResult", - "kind": "method", - "arity": 2 - }, { "name": "performInit", "kind": "method", @@ -1056,11 +993,6 @@ "kind": "method", "arity": 2 }, - { - "name": "readPendingEmbedBound", - "kind": "method", - "arity": 0 - }, { "name": "ready", "kind": "accessor" @@ -1180,11 +1112,6 @@ "kind": "method", "arity": 2 }, - { - "name": "resolveConnectedIds", - "kind": "method", - "arity": 1 - }, { "name": "resolveDiffEndpoint", "kind": "method", @@ -1228,7 +1155,7 @@ { "name": "rrfFusion", "kind": "method", - "arity": 3 + "arity": 4 }, { "name": "runAggregationBackfillWalk", @@ -1348,11 +1275,6 @@ "kind": "method", "arity": 1 }, - { - "name": "textIdsWithinBelted", - "kind": "method", - "arity": 2 - }, { "name": "trackField", "kind": "method", @@ -1491,23 +1413,12 @@ "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 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 c4757030..fc530baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.12", + "version": "10.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@soulcraftlabs/brainy", - "version": "10.4.12", + "version": "10.4.9", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.2", diff --git a/package.json b/package.json index 649f2aaf..f07bb94c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.12", + "version": "10.4.9", "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 --config tests/configs/vitest.perf.config.ts", + "test:perf": "vitest run tests/unit/performance --reporter=basic", "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 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 d4ec7ba5..00000000 --- a/scripts/wall-entry.mjs +++ /dev/null @@ -1,504 +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) - } -} - -/** - * 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 - } - - try { - git(['add', `${product}.json`], cacheDir) - git(['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 b8eb7f56..ffc2d5cd 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -531,19 +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 - /** 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 @@ -780,50 +767,6 @@ 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 @@ -833,50 +776,6 @@ 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 @@ -946,24 +845,6 @@ 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 @@ -2143,98 +2024,113 @@ 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 */ private registerShutdownHooks(): void { /** - * The signal-path shutdown. ONE OWNER PER BRAIN, AND THE PATH IS `close()`. + * The signal-path shutdown. THREE LAWS, each written by a production + * shutdown that looked clean and wasn't: * - * 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. + * 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. */ - const closeOnShutdown = async () => { + const flushOnShutdown = async () => { console.log('Shutdown signal received - flushing pending data...') - // 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 flushedCount = 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++ + // 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() + } + })() + ]) + + // 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 close one Brainy instance on shutdown:', error) + 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) + } + try { + if (instance.storage && typeof instance.storage.releaseWriterLock === 'function') { + await instance.storage.releaseWriterLock() + } + } catch (error) { + console.error('Failed to release the writer lock 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 (flushedCount > 0) { + console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 1 ? 's' : ''})`) } if (failedCount > 0) { console.error( @@ -2244,106 +2140,6 @@ export class Brainy implements BrainyInterface { } } - /** - * 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 // kept as statics so the last live instance's close() can deregister them // — the signal handles they hold are ref'd and would otherwise keep the @@ -2361,39 +2157,32 @@ 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 = (ownersWhenSignalled: number): void => { - if (ownersWhenSignalled <= 1) { + const exitIfSoleShutdownOwner = (signal: 'SIGTERM' | 'SIGINT'): void => { + if (process.listenerCount(signal) <= 1) { process.exit(0) } } Brainy.sigtermListener = async () => { - const owners = process.listenerCount('SIGTERM') - await closeOnShutdown() - exitIfSoleShutdownOwner(owners) + await flushOnShutdown() + exitIfSoleShutdownOwner('SIGTERM') } Brainy.sigintListener = async () => { - const owners = process.listenerCount('SIGINT') - await closeOnShutdown() - exitIfSoleShutdownOwner(owners) + 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() } - Brainy.beforeExitListener = flushOnDrainedEventLoop process.on('SIGTERM', Brainy.sigtermListener) process.on('SIGINT', Brainy.sigintListener) process.on('beforeExit', Brainy.beforeExitListener) @@ -2415,11 +2204,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 } @@ -2470,33 +2254,6 @@ 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 * @@ -2677,47 +2434,6 @@ 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 @@ -2732,9 +2448,6 @@ 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() } } @@ -2745,27 +2458,11 @@ 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. 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. + * is re-reaped there — a bounded no-op, never a lost vector. */ - private clearPendingEmbed( - id: string, - durability: 'durable' | 'in-memory-only' = 'durable' - ): void { + private clearPendingEmbed(id: string): 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() } /** @@ -2801,223 +2498,6 @@ 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` @@ -3026,22 +2506,14 @@ 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 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}. + * 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. * 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 @@ -3054,13 +2526,20 @@ export class Brainy implements BrainyInterface { private async recoverPendingEmbedsFromLog(): Promise { const log = this.generationStore.getFactLog() if (!log || !log.hasV2History()) return - const { bound, fromGeneration, seeded } = await this.readPendingEmbedBound() - for (const id of seeded) this._pendingEmbedIds.add(id) - let factsScanned = 0 + 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 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) @@ -3075,21 +2554,6 @@ 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` - ) } /** @@ -3181,23 +2645,11 @@ export class Brainy implements BrainyInterface { for (const id of batch) { try { const entity = await this.get(id, { includeVectors: true }) - 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') + 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) continue } // Hang guard: a wedged embedder must not block every later pending @@ -3470,18 +2922,9 @@ 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() @@ -4193,16 +3636,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) { @@ -4249,170 +3682,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>> { - if (fields === undefined || fields.length === 0) return this.batchGet(ids) - - const wanted = [...new Set([...fields, ...guardFields])] - const provider = this.metadataIndex as unknown as MetadataIndexProvider - let served = new Map>() - if (typeof provider.getScalarsForIds === 'function') { - served = await provider.getScalarsForIds(ids, wanted) - } - - // Which ids still owe a field? Only those cost a record read, and a page - // that owes nothing costs none at all. - const owing: string[] = [] - for (const id of ids) { - const row = served.get(id) - if (row === undefined || wanted.some((f) => !(f in row))) owing.push(id) - } - - // The records are read for the OWED fields only; everything the index - // already served is used as-is, so a body field pulls its own record and - // no more than that. - const records = owing.length > 0 ? await this.batchGet(owing) : new Map>() - - const out = new Map>() - for (const id of ids) { - const fromIndex = served.get(id) - const record = records.get(id) - // An id neither the index nor storage knows is not a row. - if (fromIndex === undefined && record === undefined) continue - out.set(id, this.#projectEntity(id, wanted, fromIndex, record)) - } - return out - } - - /** - * Build one projected entity: `id`, plus exactly the requested fields that - * something could supply. - * - * Values come from the index first and the record second, and they must agree - * — the index only reports what it can serve exactly, so a field it served is - * the record's value. A field neither has is omitted rather than set to - * `undefined`: absent and present-and-undefined are different answers, and a - * caller checking `'slug' in row.metadata` deserves the true one. - * - * @param id - The entity id, always present on the result. - * @param fields - The requested index keys. - * @param fromIndex - What the index served for this id, if anything. - * @param record - The canonical entity, if one had to be read. - * @returns The projected entity. - */ - #projectEntity( - id: string, - fields: readonly string[], - fromIndex: Record | undefined, - record: Entity | undefined - ): Entity { - const projected: Record = { id } - const metadata: Record = {} - let sawMetadata = false - - for (const field of fields) { - let value: unknown - let found = false - if (fromIndex !== undefined && field in fromIndex) { - value = fromIndex[field] - found = true - } else if (record !== undefined) { - if (field.startsWith('system.')) { - const inner = field.slice('system.'.length) - const bag = record as unknown as Record - if (inner in bag && bag[inner] !== undefined) { - value = bag[inner] - found = true - } - } else { - const bag = (record.metadata ?? {}) as Record - if (field in bag) { - value = bag[field] - found = true - } - } - } - if (!found) continue - - if (field.startsWith('system.')) { - projected[field.slice('system.'.length)] = value - } else { - metadata[field] = value - sawMetadata = true - } - } - - if (sawMetadata) projected.metadata = metadata - return projected as unknown as Entity - } - async batchGet(ids: string[], options?: GetOptions): Promise>> { // Canonical read (see get): resolves by id from storage, no derived index. await this.ensureInitialized({ needs: [] }) @@ -8211,7 +7480,7 @@ export class Brainy implements BrainyInterface { // Batch-load entities for 10x faster cloud storage performance // GCS: 10 entities = 1×50ms vs 10×50ms = 500ms (10x faster) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8248,7 +7517,7 @@ export class Brainy implements BrainyInterface { if (hiddenIds.size > 0) allUuids = allUuids.filter((id) => !hiddenIds.has(id)) const pageIds = allUuids.slice(offset, offset + limit) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8276,7 +7545,7 @@ export class Brainy implements BrainyInterface { const pageIds = filteredIds.slice(offset, offset + limit) // Batch-load entities for 10x faster cloud storage performance - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8486,23 +7755,7 @@ export class Brainy implements BrainyInterface { // Rank by score (top offset+limit), then drop the offset — identical ordering // to a full `sort((a, b) => b.score - a.score)` + slice, but the native // `sort:topK` provider can compute only the page instead of the full sort. - // - // ONLY when score IS the requested order. An explicit `orderBy` names a - // different ordering key, and this block cannot serve it: it ranks by - // score and CUTS the page, so the tail's `orderBy` sort below either - // never runs at all (the early return, when there is no `connected` / - // `fusion` work left) or runs over a page that score already chose — - // ordering eight rows relevance picked instead of the eight the field - // ordering asks for. Both readings were silent: `find({ query, where, - // orderBy })` answered in score order while `find({ where, orderBy })` - // answered in field order, and nothing said the request had been dropped. - // - // With `orderBy` present the candidate set falls through UNCUT to the - // tail, which orders it in full and pages that ordering — "page last", - // the graph-first law applied to ordering rather than to filtering. The - // set is bounded by the legs (the text matches inside the universe plus - // the beam walk's `limit * 2`), not by the store. - if (!params.orderBy && results.length >= offset + limit) { + if (results.length >= offset + limit) { const k = offset + limit const order = rankIndicesByScore(results.map(r => r.score), k, true) results = reorderByIndices(results, order).slice(offset, k) @@ -8527,7 +7780,7 @@ export class Brainy implements BrainyInterface { // Batch-load entities for current page - O(page_size) instead of O(total_results) // GCS: 10 entities = 1×50ms vs 10×50ms = 500ms (10x faster) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8555,7 +7808,7 @@ export class Brainy implements BrainyInterface { // Batch-load entities for paginated results (10x faster on GCS) const sortedResults: Result[] = [] - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8660,28 +7913,6 @@ export class Brainy implements BrainyInterface { }) } - // PROJECTION TRIM — applied once, here, AFTER the integrity guard, so every - // find() path is trimmed uniformly and the guard still saw the evidence it - // needs. Hydration carried the guard's fields alongside the projection; - // this removes them, leaving exactly what the caller named. - // - // Rows that reached here from a path the seam does not hydrate (a vector or - // text leg builds its own entities) are trimmed from what they already - // hold, so the ANSWER is the same everywhere — only the cost differs, and - // only on the paths that still read a record. - if (params.fields !== undefined && params.fields.length > 0 && result.length > 0) { - const named = [...new Set(params.fields)] - result = result.map((r) => { - const projected = this.#projectEntity( - r.id, - named, - undefined, - r.entity as unknown as Entity - ) - return { ...r, entity: projected } as typeof r - }) - } - // includeVectors — opt-in vector hydration. Default (false) keeps the perf // contract: every result path above builds entities via the metadata-only // fast path, so `entity.vector` is the empty stub. When requested, fetch the @@ -13323,100 +12554,7 @@ export class Brainy implements BrainyInterface { * process.exit(0) * }) */ - flush(): Promise { - // ---- THE SINGLE-FLIGHT GATE ---- - // One flush body runs at a time, with at most ONE queued behind it. See - // `_flushInFlight` / `_flushFollowUp` for the measurement that required - // this. NOT `async`: the gate hands back the very promise the work is on, - // so joining callers share identity, not just an outcome. The gate is - // crossed BEFORE any await, so two callers in the same tick cannot both - // find the field empty. - if (this._flushInFlight) { - if (!this._flushQueued) { - // A BARE DEFERRED, not a chain off the leader — see the field's doc. - // Nothing here awaits the leader, so no waiter can ever be reachable - // only through the promise it is itself blocking. - this._flushQueued = new Promise((resolve, reject) => { - this._flushQueuedSettle = { resolve, reject } - }) - } - return this._flushQueued - } - return this.#startFlushLeader() - } - - /** - * @description Run one flush body as the leader and install it as - * `_flushInFlight`. On settle — resolved OR rejected — the gate opens and - * the ONE queued waiter (if any) is promoted. The `finally` callback returns - * nothing on purpose: a callback that returned the promoted run's promise - * would make the leader await its own follower. - * - * ECMAScript-private (`#`), not TypeScript `private`: `private` is erased at - * compile time, so the method would still land on the prototype — and the - * contract manifest reads the surface the BUILD exposes, so it would emit - * this as a contract door. A door is a promise every engine implementing the - * contract must keep; this is the flush gate's own bookkeeping. `#` keeps it - * off the prototype, where the emitter cannot see it. - * @returns The leader's own promise, settling on its own body alone. - */ - #startFlushLeader(): Promise { - const run = this._runFlush() - // `finally` and not `then`: a failed flush must still open the gate, or - // one rejection would wedge every later flush behind a promise nobody - // will ever settle. - const gated: Promise = run.finally(() => { - if (this._flushInFlight === gated) this._flushInFlight = null - this.#promoteQueuedFlush() - }) - this._flushInFlight = gated - return gated - } - - /** - * @description Promote the single queued waiter (if one is waiting) to - * leader and settle its deferred from that run. Never throws into the - * leader's `finally`: a synchronous failure starting the promoted run is - * reported to the waiter, which must be settled on every path. - * - * ECMAScript-private for the same reason as the leader starter above: - * internals are not doors. - * @returns Nothing. - */ - #promoteQueuedFlush(): void { - const settle = this._flushQueuedSettle - if (!settle) return - // Clear BEFORE starting, so the promoted run's own joiners queue afresh - // rather than joining a deferred that is already being settled. - this._flushQueued = null - this._flushQueuedSettle = null - try { - this.#startFlushLeader().then(settle.resolve, settle.reject) - } catch (error) { - settle.reject(error) - } - } - - /** - * @description The flush body — everything {@link flush} promises, run - * exactly once at a time by that method's single-flight gate. Private - * because non-overlap is part of the contract: there is no supported way to - * run two of these at once, and the counters here witness that. - * @returns Nothing. - */ - private async _runFlush(): Promise { - this._flushBodyRuns++ - this._flushBodiesActive++ - this._flushConcurrencyPeak = Math.max(this._flushConcurrencyPeak, this._flushBodiesActive) - try { - await this._flushSteps() - } finally { - this._flushBodiesActive-- - } - } - - /** @description The flush steps themselves. See {@link flush}. */ - private async _flushSteps(): Promise { + async flush(): Promise { await this.ensureInitialized() // Read-only instances have no buffered writes to flush. close() may call @@ -17064,7 +16202,7 @@ export class Brainy implements BrainyInterface { ordered = valued.map((v) => v.id) } const pageIds = ordered.slice(offset, offset + limit) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) const results: Result[] = [] for (const id of pageIds) { const entity = entitiesMap.get(id) @@ -20663,42 +19801,11 @@ export class Brainy implements BrainyInterface { * * The original failure is never swallowed: it is narrated with what it costs * the next open, then rethrown to the caller. - * - * IDEMPOTENT AND RE-ENTRANT. The teardown below runs ONCE. Concurrent - * callers share the one in-flight promise and settle together; a caller - * arriving after it finished gets that same settled promise (close is - * terminal — there is nothing left to redo, and a failed close has already - * released the lock and set `closed`). This is what makes the shutdown - * ownership question answerable at all: whoever calls first owns the close, - * everyone else — including the engine's own signal handler — joins it or - * steps aside. See `_closeInFlight`. * @returns Nothing. * @throws The first failure from the durable close steps, after the * terminal releases have run. */ - close(): Promise { - // NOT `async`: an async wrapper allocates a FRESH promise per call, so - // callers would hold different handles to the same work. Returning the - // stored promise itself makes "one close" observable identity, not just - // observable behaviour. The gate is crossed with NO await before it, so - // two callers in the same tick — and a signal handler resuming mid-close - // — always see the same answer; `isClosing` is true from this assignment - // onward. (`_closeOnce()` is async, so a failure is always a rejection, - // never a synchronous throw out of this method.) - if (this._closeInFlight) return this._closeInFlight - const run = this._closeOnce() - this._closeInFlight = run - return run - } - - /** - * @description The close body — everything {@link close} promises, run - * exactly once by that method's gate. - * @returns Nothing. - * @throws The first failure from the durable close steps, after the - * terminal releases have run. - */ - private async _closeOnce(): Promise { + async close(): Promise { if (this._pendingEmbedIds.size === 0) await this.writeEmbedLowWater() let closeFailure: unknown = null try { @@ -20787,21 +19894,6 @@ export class Brainy implements BrainyInterface { if (this._persistBackgroundFlight) { await this._persistBackgroundFlight.catch(() => {}) } - // Drain the flush chain itself: the running flush AND the single follow-up - // queued behind it. The cadence's own handle above covers only the flushes - // the cadence started — a flush-request from another process, or an - // application's own flush() racing this close, is on the chain and nowhere - // else, and a flush landing mid-close writes behind the close's work. - // Bounded by construction: at most one follow-up exists, and awaiting it - // awaits its leader too, so the second pass is a no-op unless a writer - // raced this close. - for (let pass = 0; pass < 2; pass++) { - const inFlight = this._flushInFlight - const queued = this._flushQueued - if (!inFlight && !queued) break - if (inFlight) await inFlight.catch(() => {}) - if (queued) await queued.catch(() => {}) - } // Cancel any pending post-import background deduplication FIRST — it is a // writer (merge-deletes), and no delete pass may start mid- or post-close. @@ -20844,64 +19936,40 @@ export class Brainy implements BrainyInterface { // Phase 1: Flush ALL components in parallel to persist buffered data // This is critical when cor native providers buffer data in Rust memory - // - // READ-ONLY GUARD, applied to EVERY flush here. A flush is a write by - // definition, and a reader has nothing of its own to persist — but these - // calls were not conditional, so a read-only open → read → close REWROTE - // four files under `_system/`: the metadata field registry (whose flush() - // saves it unconditionally, "even with no dirty fields"), and the three - // type/subtype statistics files the storage adapter's count flush stamps. - // Every one of them was re-stamped on a session that committed nothing. - // A reader must leave `_system/` exactly as it found it — the same law the - // clean-shutdown marker already lives under (see the generation-store - // guard below and `Brainy.openReadOnly`). await Promise.all([ // Flush HNSW dirty nodes (deferred persistence mode) (async () => { - if (this.index && !this.isReadOnly && typeof this.index.flush === 'function') { + if (this.index && typeof this.index.flush === 'function') { await this.index.flush() } })(), // Flush metadata index (field indexes + EntityIdMapper) (async () => { - if (this.metadataIndex && !this.isReadOnly && typeof this.metadataIndex.flush === 'function') { + if (this.metadataIndex && typeof this.metadataIndex.flush === 'function') { await this.metadataIndex.flush() } })(), // Flush graph adjacency index (LSM trees) (async () => { - if (this.graphIndex && !this.isReadOnly && typeof this.graphIndex.flush === 'function') { + if (this.graphIndex && typeof this.graphIndex.flush === 'function') { await this.graphIndex.flush() } })(), // Flush storage adapter counts (async () => { - if (this.storage && !this.isReadOnly && typeof this.storage.flushCounts === 'function') { + if (this.storage && typeof this.storage.flushCounts === 'function') { await this.storage.flushCounts() } })(), // Flush aggregation index state (async () => { - if (this._aggregationIndex && !this.isReadOnly) { + if (this._aggregationIndex) { await this._aggregationIndex.flush() } })(), - // 8.0 MVCC: detach the generation-bump hook and persist the counter. - // READ-ONLY GUARD: a reader's open() never sets the bump hook, never - // buffers pending single-ops, and — since generationStore.open() also - // leaves the clean-shutdown marker untouched for a reader — never - // consumes it either, so there is nothing of a writer's to persist or - // release here. Calling close() anyway would still WRITE: it - // unconditionally re-stamps `_system/clean-shutdown.json` (and can - // advance the fold checkpoint / counter files) at the generation this - // session merely observed — a reader vouching for a commit it never - // made. The marker is the writer's own evidence about the writer's own - // process; a read-only brain must leave `_system/` exactly as it found - // it. (Mirrors the same guard already applied to every other Phase-1 - // step below, and to the signal-path shutdown in - // registerShutdownHooks().) + // 8.0 MVCC: detach the generation-bump hook and persist the counter (async () => { - if (this.generationStore && !this.isReadOnly) { + if (this.generationStore) { await this.generationStore.close() } })() @@ -20914,54 +19982,23 @@ export class Brainy implements BrainyInterface { await this.stampEntityTree() } - // Phase 1c: the pending-embed CHECKPOINT — placed HERE and not earlier - // because this is the first point in the close where the durability law it - // must satisfy actually holds: `generationStore.close()` (in Phase 1 above) - // flushed the pending single-op tier, which fsyncs the fact log and then - // advances the manifest, so `head === committed` and every fact the - // checkpoint's generation covers is durable. Taken even when the set is - // NOT empty — that is the whole difference from the low-water mark, and it - // is what makes the next open's fold O(facts since this close) on a brain - // whose pending set never drains. Awaits any in-flight cadence write first - // so the last write to the file is this one. - if (!this.isReadOnly) { - await this._pendingEmbedCheckpointFlight?.catch(() => {}) - await this.writeEmbedCheckpoint() - } - // Phase 2: Close components to release resources (timers, file handles) // Data is already safe on disk from Phase 1 - // - // READ-ONLY GUARD, same law as Phase 1. Each of these closes is a WRITER: - // the graph index drains both LSM MemTables to SSTables and stamps its - // watermark, and the vector/metadata `close` hooks — optional doors the - // reference engine leaves unimplemented, but which a native provider fills - // in — persist their buffered state. None of that is a reader's to write. - // - // A reader still has to RELEASE what it holds, which is why this is a - // branch rather than a skip: `stopBackgroundFlush()` is the non-writing - // half of the graph index's close, clearing the auto-flush interval that - // would otherwise outlive the session. The optional hooks have no - // non-writing counterpart to call, and a provider that buffers nothing on - // a read-only open has nothing to release. await Promise.all([ (async () => { - if (!this.graphIndex) return - if (this.isReadOnly) { - this.graphIndex.stopBackgroundFlush() - } else if (typeof this.graphIndex.close === 'function') { + if (this.graphIndex && typeof this.graphIndex.close === 'function') { await this.graphIndex.close() } })(), (async () => { const index = this.index as JsHnswVectorIndex & VectorIndexOptionalHooks - if (index && !this.isReadOnly && typeof index.close === 'function') { + if (index && typeof index.close === 'function') { await index.close() } })(), (async () => { const metadataIndex = this.metadataIndex as MetadataIndexManager & MetadataIndexOptionalHooks - if (metadataIndex && !this.isReadOnly && typeof metadataIndex.close === 'function') { + if (metadataIndex && typeof metadataIndex.close === 'function') { await metadataIndex.close() } })(), diff --git a/src/db/errors.ts b/src/db/errors.ts index da62eb0b..3b4c1af6 100644 --- a/src/db/errors.ts +++ b/src/db/errors.ts @@ -351,63 +351,3 @@ export class PendingFlushDurabilityError extends Error { this.failedAttempts = failedAttempts } } - -/** - * @description Thrown by {@link GenerationStore.commitTransaction} when the - * PENDING single-op tier is non-empty — i.e. one or more `commitSingleOp()` - * generations are buffered in memory, not yet flushed to - * `committedRanges` via `flushPendingSingleOps()`. - * - * The invariant `reservedGensAsc()` (and everything built on it — - * `resolveManyAt`, `resolveAt`, `changedBetween`, the hot-tail window) relies - * on is documented, not enforced by types: pending generations must always be - * numerically greater than every committed one, because the ONLY sanctioned - * callers of `commitTransaction()` — `Brainy.transact()` and - * `Brainy.compactHistory()` — flush the pending tier FIRST. A caller that - * invokes `commitTransaction()` directly while single-ops are still pending - * breaks that invariant: the new commit lands in `committedRanges` ABOVE - * generations still sitting in `pendingGens`, so the committed-then-pending - * concatenation `reservedGensAsc()` yields is no longer ascending. The - * concrete failure this produces is silent, not a crash: `resolveManyAt` - * walks committed ranges before pending ones, so it can report a NEWER - * generation as the "first after" a pin than an older, still-pending one that - * actually touched the id first — a wrong before-image at a point-in-time - * read, without a compensating error to warn a caller anything went wrong. - * - * This error refuses the commit outright, before any staging I/O: nothing is - * written, the generation counter reservation is untouched, and - * `committedRanges`/`pendingGens` are exactly as they were. Call - * `flushPendingSingleOps()` first (or go through `Brainy.transact()`, which - * already does). - * - * @example - * try { - * await generationStore.commitTransaction({ touched, execute }) - * } catch (err) { - * if (err instanceof PendingSingleOpsUnflushedError) { - * await generationStore.flushPendingSingleOps() - * await generationStore.commitTransaction({ touched, execute }) // now safe - * } - * } - */ -export class PendingSingleOpsUnflushedError extends Error { - /** How many un-flushed single-op generations were buffered at refusal time. */ - public readonly pendingCount: number - - /** - * @param pendingCount - `pendingGens.length` at the moment of refusal (always ≥ 1). - */ - constructor(pendingCount: number) { - super( - `commitTransaction() refused: ${pendingCount} pending single-op generation(s) ` + - `are still buffered and un-flushed. Flush the pending single-op tier before ` + - `committing a transaction — Brainy.transact() does this automatically; a ` + - `direct commitTransaction() call with pending generations would leave the ` + - `generation order unsorted (committed generations landing above lower, ` + - `still-pending ones) and make point-in-time reads (resolveManyAt/resolveAt) ` + - `return the wrong before-image. Call flushPendingSingleOps() first, then retry.` - ) - this.name = 'PendingSingleOpsUnflushedError' - this.pendingCount = pendingCount - } -} diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index 89f83a8f..da21dc61 100644 --- a/src/db/generationStore.ts +++ b/src/db/generationStore.ts @@ -32,13 +32,7 @@ */ import { prodLog } from '../utils/logger.js' -import { - GenerationCompactedError, - GenerationConflictError, - PendingFlushDurabilityError, - PendingSingleOpsUnflushedError, - StoreInconsistentError -} from './errors.js' +import { GenerationCompactedError, GenerationConflictError, PendingFlushDurabilityError, StoreInconsistentError } from './errors.js' import type { UnreconciledRecord } from './errors.js' import { TransactionRollbackError } from '../transaction/errors.js' import type { @@ -805,16 +799,7 @@ export class GenerationStore { if (uncleanOpen) await this.advanceFoldCheckpointUnlocked() // The marker is consumed: any session that can write invalidates it // at first commit (see the commit paths); a clean close re-writes it. - // A READER NEVER CONSUMES IT. The marker is the writer's own evidence - // about the writer's own process — clearing it here exists so that - // if THIS session goes on to write and then dies before its next - // clean close, the marker's absence correctly reads as unclean. A - // reader can never write, so it can never leave the store in a state - // its own crash would mis-describe; clearing the marker for it would - // only cost the store's actual writer a needless whole-log fold on - // its next open, for a generation the reader merely observed. Leave - // `_system/` exactly as found. - if (!options?.readOnly) await this.clearCleanShutdownMarker() + await this.clearCleanShutdownMarker() } await this.factLog.open(this.committed) } else { @@ -904,11 +889,7 @@ export class GenerationStore { } } - /** - * Consume the clean-shutdown marker (every WRITER open; a clean close - * re-writes it). Callers must gate this on `!options.readOnly` — a reader - * never consumes the marker, see the call site in {@link open}. - */ + /** Consume the clean-shutdown marker (every open; a clean close re-writes it). */ private async clearCleanShutdownMarker(): Promise { try { await this.storage.deleteRawObject(CLEAN_SHUTDOWN_PATH) @@ -1370,9 +1351,6 @@ export class GenerationStore { * @param args.execute - Runs the planned operation batch atomically. * @returns The committed generation and its commit timestamp. * @throws GenerationConflictError when the CAS expectation fails. - * @throws PendingSingleOpsUnflushedError when the pending single-op tier is - * non-empty — call `flushPendingSingleOps()` first (both `Brainy.transact()` - * and `Brainy.compactHistory()` already do). */ /** * The generation fact log, or `null` when the storage layer cannot host one. @@ -1447,13 +1425,6 @@ export class GenerationStore { execute: () => Promise }): Promise<{ generation: number; timestamp: number }> { return this.withMutex(async () => { - // The generation-order guard (see assertPendingSingleOpsFlushed): a - // direct commitTransaction() call while single-ops are still pending - // would commit above them, unsorting reservedGensAsc() and corrupting - // point-in-time reads. Both sanctioned callers (Brainy.transact(), - // Brainy.compactHistory()) already flush first, so this is - // behavior-neutral on every real path. - this.assertPendingSingleOpsFlushed() // A latched history-durability failure compromises the whole generation // chain — refuse a transact too (advancing the manifest past stuck, // un-durable single-op generations would be inconsistent). Same loud @@ -2323,37 +2294,6 @@ export class GenerationStore { } } - /** - * @description Throw if the pending single-op tier is non-empty. Called at - * the top of {@link commitTransaction} (the ONLY method that appends a - * fresh commit directly into {@link committedRanges} outside recovery) so - * the ordering invariant {@link reservedGensAsc}'s own doc comment states — - * "pending generations are always greater than every committed one" — is - * ENFORCED there rather than merely assumed. - * - * That invariant holds today only because both sanctioned callers flush the - * pending tier before committing: `Brainy.transact()` (src/brainy.ts, - * `await this.generationStore.flushPendingSingleOps()` immediately before - * its `commitTransaction()` call) and `Brainy.compactHistory()` - * (src/brainy.ts, the same flush immediately before its `compact()` call — - * `compact()` itself only ever RECLAIMS an existing committed prefix, so it - * cannot land a commit out of order and needs no guard of its own). A - * caller that reaches `commitTransaction()` by any other path — bypassing - * that flush — would commit a new generation into `committedRanges` ABOVE - * generations still sitting in `pendingGens`, breaking `reservedGensAsc`'s - * "committed-then-pending is already sorted" assumption and making - * `resolveManyAt`'s single ascending pass (and `resolveAt`'s consumers) - * return the WRONG before-image for a point-in-time read — silently, no - * compensating error. Refusing here, before any staging I/O, keeps the - * store untouched (nothing committed, nothing staged, the generation - * counter reservation unaffected) on every path that already flushes. - */ - private assertPendingSingleOpsFlushed(): void { - if (this.pendingGens.length > 0) { - throw new PendingSingleOpsUnflushedError(this.pendingGens.length) - } - } - /** Schedule a coalesced pending-tier flush (size trigger fires immediately on * the next microtask; otherwise a {@link PENDING_FLUSH_DELAY_MS} timer). Both * defer outside the current mutex section so the flush can re-acquire it. A @@ -2437,13 +2377,6 @@ export class GenerationStore { * committed-then-pending concatenation is already sorted — identical to the old * `[...committedGens, ...pendingGens]`. This is the union historical reads * resolve over so un-flushed single-ops are visible to pins/`asOf`. - * - * The "flush first" half of that invariant is ENFORCED, not just documented: - * {@link commitTransaction} — the only method that lands a fresh commit into - * {@link committedRanges} outside crash recovery — refuses via - * {@link assertPendingSingleOpsFlushed} whenever {@link pendingGens} is - * non-empty, so a committed generation can never land above a still-pending - * one and break this ordering. */ private *reservedGensAsc(): IterableIterator { yield* this.committedGensAsc() diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index 2fbdbe8d..a58236e3 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -405,73 +405,3 @@ export class MigrationInProgressError extends BrainyError { } } } - -/** - * THE INDEXABLE-ARRAY BOUND. An array-valued metadata field indexes one posting - * per element, so an unbounded array is an unbounded write — a 384-float - * embedding parked in the metadata bag would mint 384 postings for one row. - * The bound exists to keep that out of the index. - * - * 256 is hardcoded on purpose (the zero-config law: no knob). It sits far above - * every legitimate multi-value field the engine has seen — tags, authors, - * categories, labels, keyword lists, participant lists — and still below the - * narrowest embedding this engine will ever meet (384 dimensions, the smallest - * model it ships), so the two populations do not overlap and no caller has to - * tune it. A vector parked in metadata is refused; a long keyword list is not. - * - * It replaces a limit of 10 that was applied SILENTLY: a row whose `tags` array - * held eleven entries had that field skipped entirely and dropped out of every - * filtered search on it, with no error, no warning and no way to tell the - * difference from "no row matches". A rule this consequential is a law with a - * name and a refusal, not a `continue`. - * - * This is the ONE place the number lives. Every message, warning, doc line and - * pin derives it from here — never a literal. - */ -export const MAX_INDEXED_ARRAY_LENGTH = 256 - -/** - * A metadata field carries an array longer than {@link MAX_INDEXED_ARRAY_LENGTH}. - * - * Thrown at the WRITE door (`add` / `update` / `relate` / `updateRelation`), so - * the caller learns at the moment of writing that the field will not be - * searchable — rather than discovering it later as rows that quietly fail to - * match. Carries the field, its length and the bound so a handler can report - * or repair without parsing the message. - * - * The cure is one of: store the long array outside the indexed bag (`data` - * carries arbitrary content and is not indexed element-wise); pass an embedding - * as the first-class `vector` parameter, which is where a vector belongs; or - * shorten the field to the values that are actually queried. - */ -export class MetadataArrayTooLargeError extends BrainyError { - /** The metadata field whose array is too long (its full dotted address). */ - public readonly field: string - /** How many elements that array holds. */ - public readonly length: number - /** The bound it exceeded — {@link MAX_INDEXED_ARRAY_LENGTH}. */ - public readonly limit: number - - constructor(site: string, field: string, length: number, limit: number) { - super( - `${site}: metadata field '${field}' holds ${length} array elements, ` + - `over the ${limit}-element indexing bound. An array field indexes one ` + - `posting per element, so an unbounded array is an unbounded write. ` + - `This write is refused rather than indexed partially or skipped silently ` + - `— a skipped field drops the row out of every filtered search on '${field}' ` + - `with no way to tell that from "nothing matched". ` + - `Cures: put the long array in 'data' (stored, not indexed element-wise); ` + - `pass an embedding as the first-class 'vector' parameter; or keep only ` + - `the values you actually query in '${field}'.`, - 'VALIDATION', - false - ) - this.name = 'MetadataArrayTooLargeError' - this.field = field - this.length = length - this.limit = limit - if (Error.captureStackTrace) { - Error.captureStackTrace(this, MetadataArrayTooLargeError) - } - } -} diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index 2c131a30..ebd3b90c 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -1105,31 +1105,13 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { } /** - * Stop the auto-flush interval WITHOUT writing anything. - * - * The non-writing half of {@link close}, for a shutdown that must leave the - * store byte-identical — a read-only brain's close. `close()` itself is a - * writer: it drains both LSM MemTables to SSTables and stamps the watermark, - * which is exactly right for a writer and forbidden for a reader. A reader - * still has to release this interval, though: it is the one piece of this - * index that outlives the close and could fire against a store the session no - * longer owns. - * - * @returns Nothing. + * Clean shutdown */ - stopBackgroundFlush(): void { + async close(): Promise { if (this.flushTimer) { clearInterval(this.flushTimer) this.flushTimer = undefined } - } - - /** - * Clean shutdown — drains both trees and stamps the watermark. THIS WRITES; - * a read-only brain must call {@link stopBackgroundFlush} instead. - */ - async close(): Promise { - this.stopBackgroundFlush() // Close both LSM-trees (will flush MemTables to SSTables) if (this.initialized) { diff --git a/src/index.ts b/src/index.ts index 673e1e6f..edc21809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -203,7 +203,7 @@ export { EntityNotFoundError, RelationNotFoundError } from './errors/notFound.js // Base error + typed migration-lock error — thrown by any data-plane call while a // brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After. -export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError, MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from './errors/brainyError.js' +export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError } from './errors/brainyError.js' export type { BrainyErrorType } from './errors/brainyError.js' // ============= 8.0 Db API — generational MVCC ============= @@ -231,8 +231,7 @@ export { GenerationCompactedError, StoreInconsistentError, PendingFlushDurabilityError, - CanonicalEnumerationUnavailableError, - PendingSingleOpsUnflushedError + CanonicalEnumerationUnavailableError } from './db/errors.js' export type { UnreconciledRecord } from './db/errors.js' export type { diff --git a/src/indexes/columnStore/ColumnStore.ts b/src/indexes/columnStore/ColumnStore.ts index 6bff86d4..4fe45bff 100644 --- a/src/indexes/columnStore/ColumnStore.ts +++ b/src/indexes/columnStore/ColumnStore.ts @@ -23,10 +23,7 @@ import type { ColumnStoreProvider, SegmentMeta } from './types.js' import { ValueType, DEFAULT_FLUSH_THRESHOLD, - FLAG_MULTI_VALUE, - POSTING_KINDS, - KIND_PATH_SEGMENT, - type PostingKind + FLAG_MULTI_VALUE } from './types.js' import { ColumnTailBuffer } from './ColumnTailBuffer.js' import { ColumnManifest } from './ColumnManifest.js' @@ -55,89 +52,10 @@ interface HeapEntry { value: number | string entityIntId: number cursorIndex: number - /** - * Rank of the posting kind this entry came from, from {@link POSTING_KINDS}. - * A mixed-kind field has no natural total order, so the merge orders by kind - * first and by value within a kind. - */ - kindRank: number /** Iterator for the cursor — call next() to advance */ iterator: Generator } -/** - * One physical posting column: a (field, kind) pair and the key every internal - * map and every storage path uses for it. - */ -interface KindColumn { - /** The field as the query language names it. */ - field: string - /** The kind of value this column holds. */ - kind: PostingKind - /** - * Internal map / storage key. The field's PRIMARY kind uses the bare field - * name — the historical layout — and every other kind uses - * `//`. - */ - key: string -} - -/** - * The KIND a value indexes under — its JavaScript `typeof` class, not its - * storage encoding. - * - * Anything that is not a number, string or boolean indexes as a string, which - * is the `String(value)` treatment those values already received. `null` and - * `undefined` never reach here: `addEntity` skips them, and their absence is - * what the `exists` / `missing` operators read. - * - * @param value - The value about to be indexed or queried - * @returns The posting kind that owns this value - */ -function kindOfValue(value: unknown): PostingKind { - const t = typeof value - if (t === 'number') return 'number' - if (t === 'boolean') return 'boolean' - return 'string' -} - -/** - * The segment encoding a fresh column of this kind starts with. - * - * Only the number kind has a choice: an integer column starts as i64 and - * widens to f64 the first time a non-integer arrives - * ({@link ColumnTailBuffer.promoteToFloat}). - */ -function initialValueTypeFor(kind: PostingKind, firstValue: unknown): ValueType { - switch (kind) { - case 'boolean': - return ValueType.Boolean - case 'string': - return ValueType.String - case 'number': - return Number.isInteger(firstValue) ? ValueType.Number : ValueType.Float - } -} - -/** - * The kind a column of this encoding holds — the inverse of - * {@link initialValueTypeFor}, used to read a kind back off a manifest written - * before typed postings existed. - */ -function kindOfValueType(valueType: ValueType): PostingKind { - switch (valueType) { - case ValueType.Boolean: - return 'boolean' - case ValueType.String: - return 'string' - case ValueType.Number: - case ValueType.Float: - return 'number' - default: - throw new Error(`Unknown ValueType: ${valueType}`) - } -} - /** * Unified column store coordinator. * @@ -203,19 +121,9 @@ export class ColumnStore implements ColumnStoreProvider { */ private deletedEntities: Map = new Map() - /** Segment encoding per COLUMN key (not per field — a field has one per kind). */ + /** Known field value types (inferred from first write). */ private fieldTypes: Map = new Map() - /** - * Every posting column a field owns: field → kind → column key. - * - * This is the map that ends the first-writer type freeze. A field's first - * kind takes the bare field name as its column key, keeping the historical - * on-disk layout; each later kind takes its own column beside it. Nothing is - * coerced across kinds and nothing is dropped for being the wrong type. - */ - private fieldColumns: Map> = new Map() - /** Whether init() has completed. */ private initialized = false @@ -232,128 +140,6 @@ export class ColumnStore implements ColumnStoreProvider { this.l0CompactionTrigger = config?.l0CompactionTrigger ?? 4 } - // ========================================================================= - // Posting columns: (field, kind) → one physical column - // ========================================================================= - - /** - * Storage / map key for a (field, kind) column. - * - * `primary` is the kind that owns the bare field name. It is whichever kind - * the field saw first, which for an index written before typed postings is - * simply the kind of its single manifest — so the historical layout is - * preserved rather than migrated. - */ - private static columnKeyFor(field: string, kind: PostingKind, primary: PostingKind | null): string { - return primary === null || kind === primary - ? field - : `${field}/${KIND_PATH_SEGMENT}/${kind}` - } - - /** - * Split a discovered manifest path back into its (field, kind) column, or - * `null` when the path names a field's primary column rather than a kind - * column. `/k/` is the only shape that reads as a kind column, - * and only for a `` this version knows. - */ - private static parseKindColumnKey(key: string): { field: string; kind: PostingKind } | null { - const marker = `/${KIND_PATH_SEGMENT}/` - const at = key.lastIndexOf(marker) - if (at <= 0) return null - const kind = key.slice(at + marker.length) - if (!POSTING_KINDS.includes(kind as PostingKind)) return null - return { field: key.slice(0, at), kind: kind as PostingKind } - } - - /** Record a discovered or freshly created column against its field. */ - private registerColumn(field: string, kind: PostingKind, key: string): void { - let byKind = this.fieldColumns.get(field) - if (!byKind) { - byKind = new Map() - this.fieldColumns.set(field, byKind) - } - const existing = byKind.get(kind) - if (existing !== undefined && existing !== key) { - // Two columns claiming one (field, kind) means the layout on disk is not - // one this writer could have produced. Serving it would silently answer - // from half the postings, so say which two and stop. - throw new Error( - `ColumnStore: field '${field}' has two '${kind}' posting columns on ` + - `disk ('${existing}' and '${key}'). The column index layout is ` + - `inconsistent — rebuild/repair the metadata index rather than ` + - `serving from one half of it.` - ) - } - byKind.set(kind, key) - } - - /** The column key for this (field, kind), or `null` if the field has no such kind. */ - private columnKey(field: string, kind: PostingKind): string | null { - return this.fieldColumns.get(field)?.get(kind) ?? null - } - - /** - * The column key for this (field, kind), creating the registration if the - * field has not seen this kind before. Write path only. - */ - private ensureColumnKey(field: string, kind: PostingKind): string { - const byKind = this.fieldColumns.get(field) - const existing = byKind?.get(kind) - if (existing !== undefined) return existing - - // The primary kind is the one already holding the bare field name, if any. - let primary: PostingKind | null = null - if (byKind) { - for (const [k, key] of byKind) { - if (key === field) { primary = k; break } - } - } - const key = ColumnStore.columnKeyFor(field, kind, primary) - this.registerColumn(field, kind, key) - return key - } - - /** - * Every posting column this field owns, in {@link POSTING_KINDS} order. - * - * Read doors that are not about one particular value — an unbounded range - * used as an "any value present" probe, distinct values, sorting — fan out - * over all of them. - */ - private columnsForField(field: string): KindColumn[] { - const byKind = this.fieldColumns.get(field) - if (!byKind) return [] - const out: KindColumn[] = [] - for (const kind of POSTING_KINDS) { - const key = byKind.get(kind) - if (key !== undefined) out.push({ field, kind, key }) - } - return out - } - - /** - * Which value kinds this field actually holds, in {@link POSTING_KINDS} - * order — the honest answer to "what type is this field?". - * - * A field that carries both `'electronics'` and `5` reports - * `['number', 'string']`, not whichever of them was written first. - * - * @param field - Field name - * @returns Every kind with at least one posting, or `[]` for an unknown field - */ - getFieldKinds(field: string): PostingKind[] { - return this.columnsForField(field) - .filter((c) => this.columnHasData(c.key)) - .map((c) => c.kind) - } - - /** Does this physical column hold any postings (persisted or buffered)? */ - private columnHasData(key: string): boolean { - const manifest = this.manifests.get(key) - const buffer = this.tailBuffers.get(key) - return (manifest !== undefined && !manifest.isEmpty()) || (buffer !== undefined && buffer.size > 0) - } - /** * Initialize the column store: discover existing field manifests. */ @@ -371,23 +157,11 @@ export class ColumnStore implements ColumnStoreProvider { }).listObjectsUnderPath(this.basePath + '/') for (const path of paths) { if (path.endsWith('/MANIFEST.json')) { - // The discovered name is a COLUMN key: either a bare field (that - // field's primary kind, which is every column an index written - // before typed postings has) or `/k/` for a second - // kind that arrived on a field later. - const columnKey = path.replace(this.basePath + '/', '').replace('/MANIFEST.json', '') - const manifest = new ColumnManifest(columnKey, this.basePath) + const fieldName = path.replace(this.basePath + '/', '').replace('/MANIFEST.json', '') + const manifest = new ColumnManifest(fieldName, this.basePath) await manifest.load(storage) - this.manifests.set(columnKey, manifest) - this.fieldTypes.set(columnKey, manifest.valueType) - - const parsed = ColumnStore.parseKindColumnKey(columnKey) - if (parsed) { - this.registerColumn(parsed.field, parsed.kind, columnKey) - } else { - this.registerColumn(columnKey, kindOfValueType(manifest.valueType), columnKey) - } - const fieldName = columnKey + this.manifests.set(fieldName, manifest) + this.fieldTypes.set(fieldName, manifest.valueType) // Load global deleted bitmap if it exists. Raw blob preferred // (2.4.0 #4 cortex-shared format); legacy envelope fallback for @@ -490,43 +264,26 @@ export class ColumnStore implements ColumnStoreProvider { /** * Point filter: find entities where field equals value. * - * The QUERY VALUE'S OWN KIND picks the posting column, and only that column - * is read. `where {category: 5}` answers from the number postings and - * `where {category: '5'}` from the string postings — neither borrows the - * other's rows, because a row written with the number `5` is not a row whose - * category is the text `'5'`. - * - * A field that has never seen this kind matches nothing, which is the true - * answer rather than a coerced one. - * - * Searches all segments + tail buffer of that column, returns the union as a - * roaring bitmap. Excludes globally deleted entities. + * Searches all segments + tail buffer, returns union as roaring bitmap. + * Excludes globally deleted entities. */ async filter(field: string, value: unknown): Promise { const result = new RoaringBitmap32() - const columnKey = this.columnKey(field, kindOfValue(value)) - if (columnKey === null) return result - - // The query value takes the column's encoding — a boolean queried against - // a boolean column has to become the 1/0 the column stores. - const encoded = this.normalizeValue(value, this.fieldTypes.get(columnKey) ?? ValueType.String) - if (encoded === undefined) return result - - const deleted = this.deletedEntities.get(columnKey) + const deleted = this.deletedEntities.get(field) // Search segments - const cursors = await this.getSegmentCursors(columnKey) + const cursors = await this.getSegmentCursors(field) for (const cursor of cursors) { - const ids = cursor.getEntityIdsForValue(encoded) + const ids = cursor.getEntityIdsForValue(value as number | string) for (const id of ids) { if (!deleted || !deleted.has(id)) result.add(id) } } // Search tail buffer - const tailCursor = this.getTailBufferCursor(columnKey) + const tailCursor = this.getTailBufferCursor(field) if (tailCursor) { - const ids = tailCursor.getEntityIdsForValue(encoded) + const ids = tailCursor.getEntityIdsForValue(value as number | string) for (const id of ids) { if (!deleted || !deleted.has(id)) result.add(id) } @@ -535,62 +292,6 @@ export class ColumnStore implements ColumnStoreProvider { return result } - /** - * Read this column's value for each of `entityIntIds` — the per-id read - * behind `find({ fields })`. - * - * Every other read door here answers "which entities have this value". A - * projection asks the opposite — "what value does this entity have" — and - * without it a projection has to go to the canonical record for a field the - * column is already holding. - * - * The column is walked ONCE and the wanted ids are picked out as they pass, - * so the cost is O(column) per field rather than O(ids x column). Later - * sources win: the tail buffer holds writes newer than any segment, and - * within the segments a later one supersedes an earlier, exactly as `filter` - * treats them. - * - * Values are EXACT — this store keeps raw values, not the bucketed form the - * sparse index uses for range queries — which is what makes it safe to - * project from. Deleted entities are skipped; an id with no value in this - * column is simply absent from the result. - * - * @param field - Field name to read. - * @param entityIntIds - Entity integer ids to read values for. - * @returns `entityIntId -> value` for the ids this column holds. - */ - async valuesForIds( - field: string, - entityIntIds: Iterable - ): Promise> { - const wanted = new Set(entityIntIds) - const out = new Map() - if (wanted.size === 0 || !this.hasField(field)) return out - - // Every kind the field holds is read, in POSTING_KINDS order — a value an - // entity wrote as a string is still that entity's value for this field. - for (const column of this.columnsForField(field)) { - const deleted = this.deletedEntities.get(column.key) - const take = (entry: { value: number | string; entityIntId: number }): void => { - if (!wanted.has(entry.entityIntId)) return - if (deleted && deleted.has(entry.entityIntId)) return - out.set(entry.entityIntId, entry.value) - } - - // Segments oldest -> newest, then the tail: a later write overwrites an - // earlier one for the same id. - const cursors = await this.getSegmentCursors(column.key) - for (const cursor of cursors) { - for (const entry of cursor.iterateForward()) take(entry) - } - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - for (const entry of tailCursor.iterateForward()) take(entry) - } - } - return out - } - /** * Range filter: find entities where field is within the bounds. * @@ -610,59 +311,41 @@ export class ColumnStore implements ColumnStoreProvider { includeMax: boolean = true ): Promise { const result = new RoaringBitmap32() + const cursors = await this.getSegmentCursors(field) const hasMin = min !== undefined && min !== null const hasMax = max !== undefined && max !== null - // The BOUNDS pick the column: numeric bounds read the numeric postings, - // string bounds the string postings. An unbounded call is not a range at - // all — it is the "has any value here" probe behind `exists` — so it fans - // out over every kind the field holds. - const columns: KindColumn[] = hasMin - ? this.columnsForKind(field, kindOfValue(min)) - : hasMax - ? this.columnsForKind(field, kindOfValue(max)) - : this.columnsForField(field) + for (const cursor of cursors) { + const lo = hasMin ? min as number | string : cursor.minValue + const hi = hasMax ? max as number | string : cursor.maxValue + if (lo === undefined || hi === undefined) continue + // Exclusivity applies only to an explicitly provided bound. A bound taken + // from the segment's own min/max is a real stored value and must stay + // inclusive, or the segment's boundary entities would be wrongly dropped. + const ids = cursor.getEntityIdsInRange( + lo, + hi, + hasMin ? includeMin : true, + hasMax ? includeMax : true + ) + for (const id of ids) result.add(id) + } - for (const column of columns) { - const cursors = await this.getSegmentCursors(column.key) - for (const cursor of cursors) { - const lo = hasMin ? min as number | string : cursor.minValue - const hi = hasMax ? max as number | string : cursor.maxValue - if (lo === undefined || hi === undefined) continue - // Exclusivity applies only to an explicitly provided bound. A bound taken - // from the segment's own min/max is a real stored value and must stay - // inclusive, or the segment's boundary entities would be wrongly dropped. - const ids = cursor.getEntityIdsInRange( - lo, - hi, - hasMin ? includeMin : true, - hasMax ? includeMax : true - ) - for (const id of ids) result.add(id) - } - - // Tail buffer range: linear scan (tail is small) - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - for (const entry of tailCursor.iterateForward()) { - const v = entry.value as any - const loOk = !hasMin || (includeMin ? v >= (min as any) : v > (min as any)) - const hiOk = !hasMax || (includeMax ? v <= (max as any) : v < (max as any)) - if (loOk && hiOk) result.add(entry.entityIntId) - } + // Tail buffer range: linear scan (tail is small) + const tailCursor = this.getTailBufferCursor(field) + if (tailCursor) { + for (const entry of tailCursor.iterateForward()) { + const v = entry.value as any + const loOk = !hasMin || (includeMin ? v >= (min as any) : v > (min as any)) + const hiOk = !hasMax || (includeMax ? v <= (max as any) : v < (max as any)) + if (loOk && hiOk) result.add(entry.entityIntId) } } return result } - /** The single column for this (field, kind), as a list, or empty if absent. */ - private columnsForKind(field: string, kind: PostingKind): KindColumn[] { - const key = this.columnKey(field, kind) - return key === null ? [] : [{ field, kind, key }] - } - /** * Sort top-K: return K entity int IDs in sorted order (u64-safe BigInt). * @@ -693,21 +376,18 @@ export class ColumnStore implements ColumnStoreProvider { */ async getFilterValues(field: string): Promise { const valueSet = new Set() + const cursors = await this.getSegmentCursors(field) - for (const column of this.columnsForField(field)) { - const cursors = await this.getSegmentCursors(column.key) - - for (const cursor of cursors) { - for (const entry of cursor.iterateForward()) { - valueSet.add(String(entry.value)) - } + for (const cursor of cursors) { + for (const entry of cursor.iterateForward()) { + valueSet.add(String(entry.value)) } + } - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - for (const entry of tailCursor.iterateForward()) { - valueSet.add(String(entry.value)) - } + const tailCursor = this.getTailBufferCursor(field) + if (tailCursor) { + for (const entry of tailCursor.iterateForward()) { + valueSet.add(String(entry.value)) } } @@ -718,7 +398,9 @@ export class ColumnStore implements ColumnStoreProvider { * Check if a field has any indexed data. */ hasField(field: string): boolean { - return this.columnsForField(field).some((c) => this.columnHasData(c.key)) + const manifest = this.manifests.get(field) + const buffer = this.tailBuffers.get(field) + return (manifest !== undefined && !manifest.isEmpty()) || (buffer !== undefined && buffer.size > 0) } /** @@ -728,11 +410,12 @@ export class ColumnStore implements ColumnStoreProvider { * store will actually serve queries from. */ getIndexedFields(): string[] { - // Names FIELDS, not columns: a field carrying two kinds is one name here, - // the same name a caller queries with. const fields = new Set() - for (const [field] of this.fieldColumns) { - if (this.hasField(field)) fields.add(field) + for (const [field, manifest] of this.manifests) { + if (!manifest.isEmpty()) fields.add(field) + } + for (const [field, buffer] of this.tailBuffers) { + if (buffer.size > 0) fields.add(field) } return Array.from(fields).sort() } @@ -747,16 +430,12 @@ export class ColumnStore implements ColumnStoreProvider { getFieldSizeSummary(): Array<{ field: string; segmentCount: number; tailSize: number }> { const summary: Array<{ field: string; segmentCount: number; tailSize: number }> = [] for (const field of this.getIndexedFields()) { - // Summed across the field's kind columns — the caller asked about a - // field, and a field's size is all of the postings under its name. - let segmentCount = 0 - let tailSize = 0 - for (const column of this.columnsForField(field)) { - const manifest = this.manifests.get(column.key) - const buffer = this.tailBuffers.get(column.key) - if (manifest && !manifest.isEmpty()) segmentCount += manifest.getAllSegments().length - if (buffer) tailSize += buffer.size - } + const manifest = this.manifests.get(field) + const buffer = this.tailBuffers.get(field) + const segmentCount = manifest && !manifest.isEmpty() + ? manifest.getAllSegments().length + : 0 + const tailSize = buffer ? buffer.size : 0 summary.push({ field, segmentCount, tailSize }) } return summary @@ -784,8 +463,6 @@ export class ColumnStore implements ColumnStoreProvider { this.segmentCache.clear() this.manifests.clear() this.deletedEntities.clear() - this.fieldColumns.clear() - this.fieldTypes.clear() this.initialized = false } @@ -794,64 +471,32 @@ export class ColumnStore implements ColumnStoreProvider { // ========================================================================= /** - * Push a single value to the posting column for its (field, KIND). - * - * The value's own kind picks the column — a string goes to the field's - * string postings, a number to its number postings — so a field carrying - * `'electronics'` and `5` keeps both, each answerable by an equality filter - * of its own kind. Under the first-writer type freeze this method replaced, - * the first value's type became the field's type and every later value of - * another kind was coerced to it or, when coercion failed, dropped with no - * error at all. - * - * Creates the column's buffer and manifest on its first value. + * Push a single value to a field's tail buffer. + * Creates the buffer and manifest if first write to this field. + * Infers ValueType from the first value seen. */ private pushToBuffer(field: string, value: unknown, entityIntId: number, isMultiValue: boolean): void { - const kind = kindOfValue(value) - const columnKey = this.ensureColumnKey(field, kind) - - let buffer = this.tailBuffers.get(columnKey) + let buffer = this.tailBuffers.get(field) if (!buffer) { - // A reopened column takes its encoding from its manifest — an integer - // column that widened to f64 in an earlier session stays widened. - const valueType = - this.manifests.get(columnKey)?.valueType ?? initialValueTypeFor(kind, value) - buffer = new ColumnTailBuffer(columnKey, valueType, this.flushThreshold) - this.tailBuffers.set(columnKey, buffer) - this.fieldTypes.set(columnKey, valueType) + const valueType = this.inferValueType(value) + buffer = new ColumnTailBuffer(field, valueType, this.flushThreshold) + this.tailBuffers.set(field, buffer) + this.fieldTypes.set(field, valueType) // Ensure manifest exists - if (!this.manifests.has(columnKey)) { - const manifest = new ColumnManifest(columnKey, this.basePath) + if (!this.manifests.has(field)) { + const manifest = new ColumnManifest(field, this.basePath) manifest.valueType = valueType manifest.multiValue = isMultiValue - this.manifests.set(columnKey, manifest) + this.manifests.set(field, manifest) } } - // An integer column widens the first time a non-integer number arrives, so - // the value is stored as itself instead of rounded to the nearest integer. - if (kind === 'number' && buffer.valueType === ValueType.Number && !Number.isInteger(value)) { - buffer.promoteToFloat() - this.fieldTypes.set(columnKey, ValueType.Float) - const manifest = this.manifests.get(columnKey) - if (manifest) manifest.valueType = ValueType.Float - } - + // Normalize value to the column type const normalizedValue = this.normalizeValue(value, buffer.valueType) - if (normalizedValue === undefined) { - // Unreachable by construction: the column was chosen BY this value's - // kind, so the encoding always accepts it. Reaching here would mean a - // value had been silently dropped from the index — the exact failure - // typed postings exist to end — so it is an error, never a skip. - throw new Error( - `ColumnStore: field '${field}' rejected a ${kind} value for its own ` + - `${ValueType[buffer.valueType]} posting column. The value would have ` + - `been dropped from the index while the row stayed readable by id — ` + - `this is a kind-routing bug, not a value the caller may ignore.` - ) + if (normalizedValue !== undefined) { + buffer.add(normalizedValue, entityIntId) } - buffer.add(normalizedValue, entityIntId) } /** @@ -980,15 +625,8 @@ export class ColumnStore implements ColumnStoreProvider { /** Torn-segment quarantine entries for a field (observability + heal input). */ quarantinedSegments(field: string): Array<{ segment: string; error: string; hits: number }> { const out: Array<{ segment: string; error: string; hits: number }> = [] - // Across every kind column of the field — a torn segment in the string - // postings is this field's torn segment as much as one in the numbers. - for (const column of this.columnsForField(field)) { - const prefix = `${column.key}:` - for (const [key, q] of this.segmentQuarantine) { - if (key.startsWith(prefix)) { - out.push({ segment: key.slice(prefix.length), error: q.error, hits: q.hits }) - } - } + for (const [key, q] of this.segmentQuarantine) { + if (key.startsWith(`${field}:`)) out.push({ segment: key.slice(field.length + 1), error: q.error, hits: q.hits }) } return out } @@ -1160,22 +798,17 @@ export class ColumnStore implements ColumnStoreProvider { k: number, filterBitmap: RoaringBitmap32 | null ): Promise { - // Collect cursors across EVERY kind the field holds. A single-kind field — - // nearly all of them — merges exactly the cursors it always did. + // Collect all cursors (segments + tail buffer) + const segCursors = await this.getSegmentCursors(field) + const tailCursor = this.getTailBufferCursor(field) + + // Create iterators for each cursor in the specified direction const iterators: Generator[] = [] - const iteratorKindRank: number[] = [] - for (const column of this.columnsForField(field)) { - const kindRank = POSTING_KINDS.indexOf(column.kind) - const segCursors = await this.getSegmentCursors(column.key) - for (const cursor of segCursors) { - iterators.push(order === 'asc' ? cursor.iterateForward() : cursor.iterateBackward()) - iteratorKindRank.push(kindRank) - } - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - iterators.push(order === 'asc' ? tailCursor.iterateForward() : tailCursor.iterateBackward()) - iteratorKindRank.push(kindRank) - } + for (const cursor of segCursors) { + iterators.push(order === 'asc' ? cursor.iterateForward() : cursor.iterateBackward()) + } + if (tailCursor) { + iterators.push(order === 'asc' ? tailCursor.iterateForward() : tailCursor.iterateBackward()) } if (iterators.length === 0) return [] @@ -1189,21 +822,16 @@ export class ColumnStore implements ColumnStoreProvider { value: next.value.value, entityIntId: next.value.entityIntId, cursorIndex: i, - kindRank: iteratorKindRank[i], iterator: iterators[i] }) } } - // Heapify. A number and a string have no ordering between them, so a - // mixed-kind field orders by KIND first (POSTING_KINDS order) and by value - // within a kind — one defined total order instead of a comparison whose - // answer depends on which value happened to be on the left. + // Heapify + const isString = (this.fieldTypes.get(field) ?? ValueType.Number) === ValueType.String const compare = (a: HeapEntry, b: HeapEntry): number => { let cmp: number - if (a.kindRank !== b.kindRank) { - cmp = a.kindRank - b.kindRank - } else if (POSTING_KINDS[a.kindRank] === 'string') { + if (isString) { cmp = compareCodePoints(String(a.value), String(b.value)) } else { cmp = (a.value as number) - (b.value as number) @@ -1235,7 +863,6 @@ export class ColumnStore implements ColumnStoreProvider { value: next.value.value, entityIntId: next.value.entityIntId, cursorIndex: top.cursorIndex, - kindRank: top.kindRank, iterator: top.iterator } } @@ -1243,11 +870,8 @@ export class ColumnStore implements ColumnStoreProvider { this.heapDown(heap, 0, compare) } - // Apply global deleted check, filter, and dedup. The deleted bitmap is - // per COLUMN, and the entry came from the column its kind names. - const deleted = this.deletedEntities.get( - this.columnKey(field, POSTING_KINDS[top.kindRank]) ?? field - ) + // Apply global deleted check, filter, and dedup + const deleted = this.deletedEntities.get(field) if (deleted && deleted.has(top.entityIntId)) continue if (seen.has(top.entityIntId)) continue if (filterBitmap && !filterBitmap.has(top.entityIntId)) continue @@ -1289,31 +913,35 @@ export class ColumnStore implements ColumnStoreProvider { } /** - * Encode a value for the column its own kind selected. - * - * This does NOT convert between kinds. It used to: a string reaching a - * numeric column was run through `Number(value)`, and a number reaching a - * numeric column was run through `Math.round`, so `'electronics'` became - * `NaN` and vanished while `4.5` became `5` and answered the wrong query. - * Kind routing removes the need for either — the only work left is picking - * the encoding the column already committed to. - * - * @returns The encoded value, or `undefined` if the value does not belong in - * this column at all — which the caller treats as a routing bug and - * raises, never as a value to skip. + * Infer ValueType from a JavaScript value. + */ + private inferValueType(value: unknown): ValueType { + if (typeof value === 'boolean') return ValueType.Boolean + if (typeof value === 'number') { + return Number.isInteger(value) ? ValueType.Number : ValueType.Float + } + return ValueType.String + } + + /** + * Normalize a JavaScript value to the column's ValueType. */ private normalizeValue(value: unknown, type: ValueType): number | string | undefined { switch (type) { case ValueType.Number: - // Integer column. Non-integers widen it to Float before reaching here. - return typeof value === 'number' && Number.isInteger(value) ? value : undefined + if (typeof value === 'number') return Math.round(value) + if (typeof value === 'string') { const n = Number(value); return isNaN(n) ? undefined : Math.round(n) } + if (typeof value === 'boolean') return value ? 1 : 0 + return undefined case ValueType.Float: - return typeof value === 'number' ? value : undefined + if (typeof value === 'number') return value + if (typeof value === 'string') { const n = Number(value); return isNaN(n) ? undefined : n } + return undefined case ValueType.Boolean: - return typeof value === 'boolean' ? (value ? 1 : 0) : undefined + if (typeof value === 'boolean') return value ? 1 : 0 + if (typeof value === 'number') return value ? 1 : 0 + return undefined case ValueType.String: - // The string kind is also where objects and bigints land, exactly as - // they always did. return String(value) default: return undefined diff --git a/src/indexes/columnStore/ColumnTailBuffer.ts b/src/indexes/columnStore/ColumnTailBuffer.ts index e730f884..c5874ac2 100644 --- a/src/indexes/columnStore/ColumnTailBuffer.ts +++ b/src/indexes/columnStore/ColumnTailBuffer.ts @@ -55,12 +55,8 @@ export class ColumnTailBuffer { /** Field name this buffer is for. */ readonly fieldName: string - /** - * Value type determines sort comparator and segment encoding. - * - * Widened in place by {@link promoteToFloat} — never otherwise reassigned. - */ - valueType: ValueType + /** Value type determines sort comparator. */ + readonly valueType: ValueType /** Flush threshold. */ readonly threshold: number @@ -85,38 +81,6 @@ export class ColumnTailBuffer { this.threshold = threshold } - /** - * Widen an integer column to floating point, losslessly and in place. - * - * The number posting kind holds every JavaScript number, but a segment picks - * ONE encoding: i64 for integers, f64 for the rest. A column that has only - * ever seen integers is written as i64; the first non-integer to arrive - * widens it here, so that value is stored as itself instead of being rounded - * to the nearest integer with no error — the rounding that made `4.5` and - * `5.5` both answer `where {score: 5}` and neither answer its own value. - * - * Widening is lossless in both directions it has to be: every value already - * buffered is an integer, and every integer is exactly representable as f64. - * Segments already on disk keep their own i64 encoding in their own headers - * and keep decoding by it — only segments written from here on are f64. - * - * @throws Error if called on a column that is not an integer column — the - * only legal widening is Number → Float, and any other request is a bug in - * the caller's kind routing rather than something to absorb quietly. - */ - promoteToFloat(): void { - if (this.valueType === ValueType.Float) return - if (this.valueType !== ValueType.Number) { - throw new Error( - `ColumnTailBuffer '${this.fieldName}': cannot widen a ` + - `${ValueType[this.valueType]} column to Float — only an integer ` + - `(Number) column widens, and this call means a value reached the ` + - `wrong kind's column` - ) - } - this.valueType = ValueType.Float - } - /** * Add a (value, entityIntId) entry to the buffer. * diff --git a/src/indexes/columnStore/types.ts b/src/indexes/columnStore/types.ts index ee949bd0..71dd99a0 100644 --- a/src/indexes/columnStore/types.ts +++ b/src/indexes/columnStore/types.ts @@ -58,53 +58,6 @@ export enum ValueType { Boolean = 3 } -/** - * The KIND of a value, as the query language sees it. - * - * A kind is a JavaScript `typeof` class, not a storage encoding: `5` and `5.5` - * are one kind (`'number'`) held in one posting column, even though they need - * different segment encodings (i64 vs f64 — see {@link ValueType}). - * - * A field holds ONE POSTING COLUMN PER KIND, so `category` may carry string - * values and number values at the same time and answer equality on each. This - * replaces the first-writer type freeze, under which the first value's type - * became the field's type and every later value of another kind was coerced — - * or, when coercion failed (`Number('electronics')`), dropped from the index - * with no error: the row stayed readable by id and by vector but vanished from - * every equality filter on that field. - * - * Kinds do not coerce into one another at query time either: `where {c: 5}` - * matches rows written with the NUMBER `5`, and `where {c: '5'}` matches rows - * written with the STRING `'5'`. Neither ever matches the other. - * - * Values that are none of these three (objects, bigints) index as strings — - * the same `String(value)` treatment they received before. - */ -export type PostingKind = 'number' | 'string' | 'boolean' - -/** - * Every posting kind, in the order that defines cross-kind sort position. - * - * A mixed-kind field has no natural total order — a number does not compare - * with a string — so `sortTopK` orders by KIND first (numbers, then strings, - * then booleans) and by value within a kind. A single-kind field, which is - * nearly every field, sorts exactly as it always did. - */ -export const POSTING_KINDS: readonly PostingKind[] = ['number', 'string', 'boolean'] - -/** - * Path segment marking a field's NON-PRIMARY kind columns on disk. - * - * The first kind a field ever sees keeps the historical layout — - * `//MANIFEST.json` and `//L0-NNNNNN` — so every - * index written before typed postings opens unchanged, and the byte-for-byte - * interchange with the native column store is untouched for the single-kind - * fields that are nearly all of them. A second kind arriving on the same field - * gets its own column at `//k//…` rather than overwriting or - * being coerced into the first. - */ -export const KIND_PATH_SEGMENT = 'k' - // --------------------------------------------------------------------------- // Segment header and footer // --------------------------------------------------------------------------- @@ -314,19 +267,6 @@ export interface ColumnStoreProvider { */ hasField(field: string): boolean - /** - * Which value KINDS this field actually holds, in {@link POSTING_KINDS} - * order — the honest answer to "what type is this field?" for a field that - * carries more than one. - * - * OPTIONAL so an implementation written against the pre-typed-postings - * contract still satisfies this interface; feature-detect before calling. - * - * @param field - Field name - * @returns Every kind with at least one posting, or `[]` for an unknown field - */ - getFieldKinds?(field: string): PostingKind[] - /** * Flush all in-memory tail buffers to L0 segments on disk. * Saves all manifests. diff --git a/src/neural/embeddedPatterns.ts b/src/neural/embeddedPatterns.ts index 92e3057a..4f4339f4 100644 --- a/src/neural/embeddedPatterns.ts +++ b/src/neural/embeddedPatterns.ts @@ -2,7 +2,7 @@ * 🧠 BRAINY EMBEDDED PATTERNS * * AUTO-GENERATED - DO NOT EDIT - * Generated: 2026-08-27T09:18:45-07:00 + * Generated: 2025-09-29T10:10:00-07:00 * Patterns: 220 * Coverage: 94-98% of all queries * diff --git a/src/plugin.ts b/src/plugin.ts index 23a8c883..64abfe26 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -495,45 +495,6 @@ export interface MetadataIndexProvider { query: string, ids: readonly string[] ): Promise> - /** - * @description OPTIONAL: read named SCALAR fields for many ids at once, from - * the index's own value storage, WITHOUT touching the canonical record. - * - * This is the door behind `find/get/related({ fields })`. A list view that - * needs a title and a slug currently hydrates the whole record for every row - * — document bodies included — and then discards almost all of it. Serving - * the named scalars from the index turns that into an index read. - * - * ## The contract, and the one rule that makes it safe - * - * **Return only what you can serve EXACTLY, and say what you served.** The - * answer is a per-id map of the fields this index actually resolved; the - * caller diffs it against what was requested and reads the canonical record - * for the remainder. An implementation must therefore OMIT a field rather - * than approximate it — and omission costs only a record read, while a wrong - * value is a wrong answer nobody can see. - * - * That rule is not hypothetical. This engine's own index buckets - * `system.createdAt` and `system.updatedAt` to the minute for range queries, - * so it cannot serve them exactly and omits them. An engine whose column - * store holds raw values can serve the same fields — so the two answer - * differently in COST and identically in CONTENT, which is the only - * difference a projection door is allowed to have. - * - * A field absent from an entity is simply absent from that entity's map. It - * is never an error, and never a `null` standing in for one: absent and - * present-and-null are different answers. - * - * @param ids - Canonical entity ids to read. - * @param fields - Index KEYS (bare = user metadata, `system.*` = engine - * scalar), already address-resolved by the caller. - * @returns `id → { field: value }` for the fields this index served exactly. - * Ids with nothing to serve may be omitted entirely. - */ - getScalarsForIds?( - ids: readonly string[], - fields: readonly string[] - ): Promise>> getSortedIdsForFilter(filter: any, orderBy: string, order?: 'asc' | 'desc', topK?: number): Promise getFilterValues(field: string): Promise getFilterFields(): Promise diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index b99f0261..a0d55c1e 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -561,33 +561,6 @@ export interface UpdateRelationParams { * refusal with the fix in hand beats a silent behavior flip. */ export interface FindParams { - /** - * **Field projection** — return only these fields on each row, instead of the - * whole record. - * - * A list view that shows a title and a slug does not need the document body, - * yet without a projection every row hydrates its full record and throws - * almost all of it away. Naming the fields lets them be served from the index - * itself: a scalar the index holds exactly is read from the index, and the - * canonical record is opened ONLY when a requested field cannot be. - * - * Field names follow the one addressing law: a bare name is the user's - * metadata (`'title'`), and `system.*` is an engine scalar - * (`'system.createdAt'`). - * - * - **Absent** ⇒ the full record, exactly as before. - * - A requested field the entity does not carry is simply **absent** from the - * row. It is never an error — a projection asks "give me these if you have - * them", so an optional field must not turn a list into a failure. - * - Every returned row carries `id` (and, on `find`, `score`) regardless: a - * row you cannot identify is not a row. - * - * @example - * // 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 }) - */ - fields?: readonly string[] - // Vector Intelligence /** Natural language or semantic search query (embedded and matched via HNSW + text index) */ query?: string @@ -816,12 +789,6 @@ export interface SimilarParams { * Added string ID shorthand syntax */ export interface RelatedParams { - // NOTE: `fields` is deliberately NOT offered here. A Relation carries `from` - // and `to` as IDS and hydrates no entity record, so there is nothing for a - // projection to trim — the param would be decorative. Projecting the - // ENDPOINTS would be a new capability (related() hydrating entities), not a - // projection of an existing one, and it belongs in its own decision. - /** * Filter by source entity ID * @@ -1447,33 +1414,6 @@ export interface ImportResult { * */ export interface GetOptions { - /** - * **Field projection** — return only these fields on each row, instead of the - * whole record. - * - * A list view that shows a title and a slug does not need the document body, - * yet without a projection every row hydrates its full record and throws - * almost all of it away. Naming the fields lets them be served from the index - * itself: a scalar the index holds exactly is read from the index, and the - * canonical record is opened ONLY when a requested field cannot be. - * - * Field names follow the one addressing law: a bare name is the user's - * metadata (`'title'`), and `system.*` is an engine scalar - * (`'system.createdAt'`). - * - * - **Absent** ⇒ the full record, exactly as before. - * - A requested field the entity does not carry is simply **absent** from the - * row. It is never an error — a projection asks "give me these if you have - * them", so an optional field must not turn a list into a failure. - * - Every returned row carries `id` (and, on `find`, `score`) regardless: a - * row you cannot identify is not a row. - * - * @example - * // 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 }) - */ - fields?: readonly string[] - /** * Include 384-dimensional vector embeddings in the response * diff --git a/src/utils/fieldTypeInference.ts b/src/utils/fieldTypeInference.ts index 0f085f8c..36a415b2 100644 --- a/src/utils/fieldTypeInference.ts +++ b/src/utils/fieldTypeInference.ts @@ -55,30 +55,8 @@ export enum FieldType { */ export interface FieldTypeInfo { field: string - /** - * The DOMINANT reading of the field — one type, the most specific one every - * sampled value satisfies. - * - * A field is not obliged to hold one kind, so this is not the whole answer - * for a field that holds several. Read {@link kinds} beside it: a field - * carrying `'electronics'` and `5` infers as STRING here and reports - * `['number', 'string']` there, and the metadata index keeps a separate - * posting column for each of them. - */ inferredType: FieldType confidence: number // 0-1 confidence score - /** - * Every value KIND observed in the sample, in the order - * number → string → boolean. More than one entry means a genuinely - * mixed field, and every one of those kinds is independently filterable. - * - * Kinds are JavaScript `typeof` classes, one level coarser than - * {@link FieldType}: a UUID and a category name are both `'string'`, and an - * integer and a timestamp are both `'number'`. - * - * Optional only for cached analyses written before this was reported. - */ - kinds?: Array<'number' | 'string' | 'boolean'> sampleSize: number // Number of values analyzed lastUpdated: number // Timestamp of last analysis detectionMethod: 'value' // Always 'value' (no fallbacks!) @@ -155,71 +133,14 @@ export class FieldTypeInference { } /** - * Analyze values to determine field type, and report every KIND the field - * actually holds alongside it. - * - * The classification below picks ONE type, because every one of its - * heuristics asks `samples.every(...)`: a field carrying `'electronics'` and - * `5` satisfies none of them and lands on STRING. That single answer is true - * as far as it goes — string is the dominant reading — but on its own it - * says nothing about the numbers also in the field, and a caller that treats - * it as the field's only type reproduces the first-writer freeze the index - * itself no longer has. {@link FieldTypeInfo.kinds} carries the rest. - */ - private async analyzeValues(field: string, values: any[]): Promise { - const info = await this.classifyValues(field, values) - info.kinds = FieldTypeInference.observedKinds(values) - if (info.kinds.length > 1 && info.metadata) { - info.metadata.format = `${info.metadata.format} (field also holds: ${info.kinds - .filter((k) => k !== FieldTypeInference.kindOfType(info.inferredType)) - .join(', ')})` - } - return info - } - - /** - * The distinct value kinds present in a sample, in a stable order. - * - * Kinds are JavaScript `typeof` classes — the same classes the metadata - * index keeps separate posting columns for — not the finer - * {@link FieldType} readings, which are interpretations layered on top of - * them (a UUID and a category name are both the `string` kind). - */ - private static observedKinds(values: any[]): Array<'number' | 'string' | 'boolean'> { - const order: Array<'number' | 'string' | 'boolean'> = ['number', 'string', 'boolean'] - const seen = new Set<'number' | 'string' | 'boolean'>() - for (const v of values) { - if (v === null || v === undefined) continue - const t = typeof v - seen.add(t === 'number' ? 'number' : t === 'boolean' ? 'boolean' : 'string') - } - return order.filter((k) => seen.has(k)) - } - - /** The value kind a {@link FieldType} reading is an interpretation of. */ - private static kindOfType(type: FieldType): 'number' | 'string' | 'boolean' { - switch (type) { - case FieldType.BOOLEAN: - return 'boolean' - case FieldType.INTEGER: - case FieldType.FLOAT: - case FieldType.TIMESTAMP_MS: - case FieldType.TIMESTAMP_S: - return 'number' - default: - return 'string' - } - } - - /** - * Classify values into a single field type. + * Analyze values to determine field type * * Uses DuckDB-inspired type detection order: * BOOLEAN → INTEGER → FLOAT → DATE → TIMESTAMP → UUID → STRING * * No fallbacks - pure value-based detection */ - private async classifyValues(field: string, values: any[]): Promise { + private async analyzeValues(field: string, values: any[]): Promise { // Filter null/undefined values const validValues = values.filter(v => v !== null && v !== undefined) diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index 1a882945..a3aa7679 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -40,7 +40,7 @@ import { import { EntityIdMapper } from './entityIdMapper.js' import { RoaringBitmap32, roaringLibraryInitialize } from './roaring/index.js' import { FieldTypeInference, FieldType } from './fieldTypeInference.js' -import { BrainyError, MAX_INDEXED_ARRAY_LENGTH } from '../errors/brainyError.js' +import { BrainyError } from '../errors/brainyError.js' /** * Fields whose values are stored in the sparse index as BUCKETED values @@ -289,10 +289,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { // No name-based exclude/allow lists — the field-addressing law: every // user field indexes, whatever its name ('content', 'data', 'id', // 'vector', … included). Bulk payloads are kept out by uniform value- - // SHAPE rules in extractIndexableFields (arrays longer than - // MAX_INDEXED_ARRAY_LENGTH never become posting scalars, and the write - // door refuses them by name; >100-char values index hashed), never by - // field name. + // SHAPE rules in extractIndexableFields (arrays >10 never become + // posting scalars; >100-char values index hashed), never by name. } // Initialize metadata cache with similar config to search cache @@ -963,41 +961,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { } /** - * Get IDs for a range using the legacy chunked sparse index (zone maps + - * roaring bitmaps). Lazy-loaded via UnifiedCache. - * - * ORDER IS NOT A KEY. This path compares NORMALIZED values, and - * {@link normalizeValue} carries an escape hatch that is order-destroying by - * design: a string over 100 characters is replaced by {@link hashValue}'s - * digest so it can be used as a filesystem-safe key. Feeding that digest to - * an ORDERING comparison — which is what a `gte` / `lt` / `between` does — - * ranks rows by hash. The result is not empty and not an error: it is a - * confidently ordered wrong answer, and it disagrees with the column-store - * path (`getIdsForRange` above), which compares raw values and is correct. - * - * Two changes hold the line here: - * - * 1. THE BOUNDS ARE NEVER HASHED. They are normalized with `allowHash = - * false`, so a long bound stays comparable instead of collapsing to a - * digest. This alone fixes the common shape — a long bound queried - * against ordinary short values, where the digest sorts below every - * letter and `gte` therefore matched the entire store. - * - * 2. A HASHED KEY IS REFUSED, NEVER GUESSED. The persisted keys are whatever - * the pre-7.20.0 writer normalized them to, so a field whose values ran - * long is stored hashed and its order is simply not recoverable from this - * index. Rather than compare digests, the query throws a typed - * `BrainyError('INVALID_QUERY')` naming the field, the bound and the cure. - * Loud beats wrong. - * - * KNOWN, NAMED DIVERGENCE. The persisted keys are also lower-cased and - * trimmed by `normalizeValue`, so this path's string ranges are - * CASE-INSENSITIVE where the column store's are not. That is a property of - * the bytes a pre-7.20.0 engine wrote, not of the comparison: the raw values - * are not in the index to compare. The bounds are normalized into the same - * case-folded space so the comparison is at least self-consistent, and the - * divergence disappears with the field itself once the column store adopts - * it. See the module note on `getIdsFromChunks` for the path's lifetime. + * Get IDs for a range using chunked sparse index with zone maps and roaring bitmaps + * Now fully lazy-loaded via UnifiedCache (no local sparseIndices Map) + * Normalize min/max for timestamp bucketing before comparison */ private async getIdsFromChunksForRange( field: string, @@ -1013,27 +979,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { } // Normalize min/max for consistent comparison with indexed values - // (indexed values are bucketed for timestamps, so we must bucket the query - // bounds too) — but NEVER through the hash escape hatch, which would make - // the bound incomparable. See the doc comment above. - const normalizedMin = min !== undefined ? this.normalizeValue(min, field, false) : undefined - const normalizedMax = max !== undefined ? this.normalizeValue(max, field, false) : undefined - - // REFUSE BEFORE SELECTING. Chunk selection itself orders values: it tests - // the bounds against each chunk's zone-map min/max. If those are hashes the - // selection is already meaningless — and its failure mode is an EMPTY - // answer (no chunk appears to overlap), which is the quietest wrong answer - // of all. So the key space is checked here, before a single chunk is - // chosen, and again per key below for a chunk whose zone map happens to - // read clean. - for (const chunkId of sparseIndex.getAllChunkIds()) { - const zoneMap = sparseIndex.getChunk(chunkId)?.zoneMap - for (const bound of [zoneMap?.min, zoneMap?.max]) { - if (typeof bound === 'string' && MetadataIndexManager.isHashedValue(bound)) { - throw MetadataIndexManager.rangeOverHashedIndex(field) - } - } - } + // (indexed values are bucketed for timestamps, so we must bucket the query bounds too) + const normalizedMin = min !== undefined ? this.normalizeValue(min, field) : undefined + const normalizedMax = max !== undefined ? this.normalizeValue(max, field) : undefined // Find candidate chunks using zone maps const candidateChunkIds = sparseIndex.findChunksForRange(normalizedMin, normalizedMax) @@ -1048,13 +996,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { const chunk = await this.chunkManager.loadChunk(field, chunkId) if (chunk) { for (const [value, bitmap] of chunk.entries) { - // A hashed key carries no order. Refuse the range rather than rank by - // digest — the whole answer is unsound, so failing on the first one - // is the honest outcome. - if (MetadataIndexManager.isHashedValue(value)) { - throw MetadataIndexManager.rangeOverHashedIndex(field) - } - // Check if value is in range using numeric-aware comparison // (normalizeValue converts numbers to strings, so we must compare numerically) let inRange = true @@ -1083,25 +1024,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { return this.idMapper.intsIterableToUuids(allIntIds) } - /** - * The refusal a range query gets when the legacy sparse index holds hashed - * keys for the field. Names the field and the cure; never a wrong answer. - */ - private static rangeOverHashedIndex(field: string): BrainyError { - return new BrainyError( - `Range query on field "${field}" cannot be served by the legacy sparse index: ` + - `its values were persisted as hashes (values over 100 characters are stored ` + - `hashed to stay within filesystem name limits), and a hash carries no order — ` + - `comparing them would return a confidently ordered wrong answer. ` + - `Equality (\`where: { ${field}: value }\`) still works on this index. ` + - `To range over this field, let the column store adopt it: run ` + - `brain.repairIndex({ rebuild: ['metadata'] }), which rebuilds the field into ` + - `the column store, where ranges compare raw values.`, - 'INVALID_QUERY', - false - ) - } - /** * Get roaring bitmap for a field-value pair without converting to UUIDs * This is used for fast multi-field intersection queries using hardware-accelerated bitmap AND @@ -1269,17 +1191,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { * value-based detection (DuckDB-inspired). Analyzes actual data values, not names. * * NO FALLBACKS - Pure value-based detection only. - * - * @param value - The value to normalize. - * @param field - Optional field name (drives the per-field statistics strategy). - * @param allowHash - Whether the >100-character escape hatch may fire. TRUE - * everywhere a normalized value is used as a KEY (equality postings, chunk - * entries, filenames) — that is what the hash exists for. FALSE on the - * ORDER-comparing path: a hash is deliberately order-destroying, so a - * bound that hashes can only be compared as nonsense. See - * {@link isHashedValue} and `getIdsFromChunksForRange`. */ - private normalizeValue(value: any, field?: string, allowHash: boolean = true): string { + private normalizeValue(value: any, field?: string): string { if (value === null || value === undefined) return '__NULL__' if (typeof value === 'boolean') return value ? '__TRUE__' : '__FALSE__' @@ -1337,34 +1250,21 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Default normalization if (typeof value === 'number') return value.toString() if (Array.isArray(value)) { - const joined = value.map(v => this.normalizeValue(v, field, allowHash)).join(',') + const joined = value.map(v => this.normalizeValue(v, field)).join(',') // Hash very long array values to avoid filesystem limits - if (allowHash && joined.length > 100) { + if (joined.length > 100) { return this.hashValue(joined) } return joined } const stringValue = String(value).toLowerCase().trim() // Hash very long string values to avoid filesystem limits - if (allowHash && stringValue.length > 100) { + if (stringValue.length > 100) { return this.hashValue(stringValue) } return stringValue } - /** - * Is this normalized value a HASH rather than the value itself? - * - * {@link hashValue} is an escape hatch for filesystem name limits, and it is - * deliberately order-destroying: two values whose hashes compare one way - * routinely compare the other way themselves. Anything that ORDERS normalized - * values has to know when it is holding one, because comparing hashes yields - * a confident, wrong answer rather than an error. - */ - private static isHashedValue(normalized: string): boolean { - return normalized.startsWith('__HASH_') - } - /** * Create a short hash for long values to avoid filesystem filename limits */ @@ -1389,10 +1289,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { * 'content', 'vector' in a bag are ordinary user fields) * - Record-frame plumbing (vector, connections, level, data, _rev, id) * never indexes — that is namespace routing, not a name carve-out - * - Value-SHAPE rules apply uniformly to all names: arrays longer than - * MAX_INDEXED_ARRAY_LENGTH never become posting scalars (and say so — - * the write door refuses them outright); purely numeric key names - * (array indices) skip; >100-char values index hashed (normalizeValue) + * - Value-SHAPE rules apply uniformly to all names: arrays >10 never + * become posting scalars; purely numeric key names (array indices) + * skip; >100-char values index hashed (normalizeValue) */ private extractIndexableFields(data: any): Array<{ field: string, value: any }> { const fields: Array<{ field: string, value: any }> = [] @@ -1454,37 +1353,13 @@ export class MetadataIndexManager implements MetadataIndexProvider { // This catches vectors stored as objects: {0: 0.1, 1: 0.2, ...} if (/^\d+$/.test(key)) continue - // THE INDEXABLE-ARRAY BOUND ({@link MAX_INDEXED_ARRAY_LENGTH}). An - // array field mints one posting per element, so the index has always - // carried a ceiling — it was 10, and it was applied by this bare - // `continue`: an eleven-element `tags` array had its whole field - // skipped and the row dropped out of every filtered search on it, with - // no error, no warning, and nothing to distinguish that from "no row - // matches". The ceiling is not the defect; the silence was. - // - // The write door refuses this shape by name now - // (`MetadataArrayTooLargeError`, thrown from paramValidation's - // `rejectOversizeIndexArrays`), so a live add/update never reaches - // here over the bound. Reaching it means the row is ALREADY on disk — - // written by an older engine under the old rule — and this is a - // rebuild, a catch-up fold or a remove reading it back. Refusing there - // would make an existing store un-rebuildable, so the row is admitted - // and the skipped field is NARRATED instead. Never silent, either way. - if (Array.isArray(value) && value.length > MAX_INDEXED_ARRAY_LENGTH) { - prodLog.warn( - `[brainy] metadata field '${fullKey}' holds ${value.length} array elements, ` + - `over the ${MAX_INDEXED_ARRAY_LENGTH}-element indexing bound — the field is ` + - `NOT indexed for this row, so it will not match a where-clause on '${fullKey}'. ` + - `This row predates the bound (the write door refuses this shape now). ` + - `Move the long array into 'data', or pass an embedding as the 'vector' parameter.` - ) - continue - } + // Skip large arrays (> 10 elements) - likely vectors or bulk data + if (Array.isArray(value) && value.length > 10) continue if (value && typeof value === 'object' && !Array.isArray(value)) { // Recurse into nested objects (but not arrays), keeping the frame extract(value, fullKey, frame) - } else if (Array.isArray(value)) { + } else if (Array.isArray(value) && value.length <= 10) { // Small arrays: index as multi-value field (all with same field name) // Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node" for (const item of value) { @@ -2930,67 +2805,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { return order === 'asc' ? comparison : -comparison } - /** - * Read named scalar fields for many ids from the COLUMN STORE, without - * touching the canonical record — the `find({ fields })` door. - * - * ## Why the column store and not the sparse index - * - * The column store keeps RAW values; the sparse index keeps a normalized, - * bucketed form built for range queries — `system.createdAt` is indexed at - * minute precision there. A projection served from the sparse index would - * hand back a value that differs from the record's, which is a wrong answer - * nobody can see. So this door reads the column store, and a field the - * column store does not hold is OMITTED rather than approximated. - * - * ## Why batched - * - * `getFieldValueForEntity` answers one (id, field) pair by walking the - * field's storage; called per row it re-walks the same column for every id. - * This walks each column ONCE and picks out every requested id as it passes: - * O(fields x column) instead of O(ids x fields x column). - * - * Omission is always safe — it costs the caller a record read. The caller - * diffs what it asked for against what came back and reads records for the - * remainder, so an index that can serve nothing is slow, never wrong. - * - * @param ids - Canonical entity ids. - * @param fields - Index keys (bare = user metadata, `system.*` = engine scalar). - * @returns `id -> { field: value }` for exactly the pairs this index served. - */ - async getScalarsForIds( - ids: readonly string[], - fields: readonly string[] - ): Promise>> { - const out = new Map>() - if (ids.length === 0 || fields.length === 0) return out - - // int -> id, so a column hit resolves back to the caller's id. An id the - // mapper does not know cannot be in any column, so it is simply absent. - const idByInt = new Map() - for (const id of ids) { - const intId = this.idMapper.getInt(id) - if (intId !== undefined) idByInt.set(intId, id) - } - if (idByInt.size === 0) return out - - for (const field of fields) { - if (!this.columnStore.hasField(field)) continue - const values = await this.columnStore.valuesForIds(field, idByInt.keys()) - for (const [intId, value] of values) { - const id = idByInt.get(intId) - if (id === undefined) continue - let row = out.get(id) - if (row === undefined) { - row = {} - out.set(id, row) - } - row[field] = value - } - } - return out - } - async getFieldValueForEntity(entityId: string, field: string): Promise { // `field` arrives as a FROZEN INDEX KEY (bare = user metadata; // 'system.' = engine scalar). Storage fallbacks read the matching diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts index f1addb5b..00790a4a 100644 --- a/src/utils/paramValidation.ts +++ b/src/utils/paramValidation.ts @@ -18,7 +18,6 @@ import { findCallerLocation } from './callerLocation.js' import * as os from 'node:os' import * as fs from 'node:fs' import { parseFieldAddress, UnsupportedFindOptionError } from '../db/fieldAddressing.js' -import { MAX_INDEXED_ARRAY_LENGTH, MetadataArrayTooLargeError } from '../errors/brainyError.js' const getSystemMemory = (): number => { if (os) { @@ -539,53 +538,8 @@ function rejectForgedSystemKeys(metadata: Record | undefined, s } } -/** - * THE INDEXABLE-ARRAY BOUND, enforced at the write door. - * - * An array-valued metadata field indexes one posting per element, so the index - * has always carried a ceiling. It used to be 10, and it was applied by a bare - * `continue` deep inside field extraction: a row whose `tags` array held eleven - * entries had that field skipped entirely and dropped out of every filtered - * search on it — no error, no warning, and no way for the caller to tell the - * difference from "no row matches". Silence is the defect; the ceiling is not. - * - * The bound is now {@link MAX_INDEXED_ARRAY_LENGTH}, high enough that every - * legitimate multi-value field clears it, and it REFUSES here instead of - * dropping data downstream. Refusing at the write door is what makes it - * actionable: the caller learns at the moment of writing, with the field, the - * length and the bound in hand. - * - * Scope is the caller's own metadata bag — the values that become postings. - * Nested bags are walked, because a nested field indexes under its dotted - * address exactly like a top-level one. Arrays of OBJECTS are not walked: the - * index only ever makes postings from an array's scalar elements. - * - * @param metadata - The caller's metadata bag (undefined is fine). - * @param site - The write door's name, for the message ('add()', 'update()', …). - * @throws {MetadataArrayTooLargeError} Naming the field, its length and the bound. - */ -function rejectOversizeIndexArrays(metadata: Record | undefined, site: string): void { - if (!metadata) return - - const walk = (bag: Record, prefix: string): void => { - for (const [key, value] of Object.entries(bag)) { - const address = prefix ? `${prefix}.${key}` : key - if (Array.isArray(value)) { - if (value.length > MAX_INDEXED_ARRAY_LENGTH) { - throw new MetadataArrayTooLargeError(site, address, value.length, MAX_INDEXED_ARRAY_LENGTH) - } - } else if (value && typeof value === 'object') { - walk(value as Record, address) - } - } - } - - walk(metadata, '') -} - export function validateAddParams(params: AddParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'add()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'add()') // 'data' is ABSENT only when null/undefined — an empty string ('') is real // content (a legitimate empty file's first write) and must not be treated // as missing. Falsy-but-present values (0, false, '') all count as present; @@ -654,7 +608,6 @@ export function validateAddParams(params: AddParams): void { */ export function validateUpdateParams(params: UpdateParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'update()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'update()') // Same absent-vs-empty distinction as validateAddParams: '' is a real new // value (e.g. truncating a file to empty content via overwrite), only // null/undefined means "no new data was given". @@ -729,7 +682,6 @@ export function validateUpdateParams(params: UpdateParams): void { */ export function validateRelateParams(params: RelateParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'relate()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'relate()') // 8.0 verb-id contract (L.7): verb ids are UUIDs, generated by brainy. // RelateParams has no `id` field — an untyped caller passing one would // previously have it silently ignored (a generated UUID was used instead). @@ -779,7 +731,6 @@ export function validateRelateParams(params: RelateParams): void { */ export function validateUpdateRelationParams(params: UpdateRelationParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'updateRelation()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'updateRelation()') if (!params.id) { throw new Error('id is required for updateRelation') } diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index bccd6fea..1a4b9fa5 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -1572,19 +1572,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { // ============= Semantic Operations ============= /** - * Search files with natural language. - * - * `options.path` scopes the search to a directory: its whole subtree by - * default, its immediate children when `recursive` is `false`. Both scopes - * are metadata filters the index SERVES, so the scope narrows the search - * before it runs — no tree walk, and never an over-fetch filtered afterwards. - * - * @param query - The natural-language query. - * @param options - Scope, metadata filters and paging (see {@link SearchOptions}). - * @returns The matching files, best first. - * @throws {VFSError} ENOENT when `recursive: false` names a path that does - * not exist (the non-recursive scope is the directory's own identity, so - * the directory has to be there). + * Search files with natural language */ async search(query: string, options?: SearchOptions): Promise { await this.ensureInitialized() @@ -1600,26 +1588,11 @@ export class VirtualFileSystem implements IVirtualFileSystem { } } - // Scope to a directory, if asked. This used to emit - // `path: { $startsWith }` — an operator that is not in the filter - // vocabulary at all, and whose `$`-less spelling the metadata index - // REFUSES by the served-operator law (an equality/range posting index - // cannot evaluate a substring without reading every row). Every - // path-scoped VFS search therefore threw, and none has ever worked on - // this engine line. Both scopes below are served shapes. + // Add path filter if specified if (options?.path) { - if (options.recursive === false) { - // Immediate children only: the directory's identity IS the scope, and - // `parent` is an indexed equality on every VFS entity. - params.where = { - ...params.where, - parent: await this.pathResolver.resolve(options.path) - } - } else { - const scope = this.descendantPathScope(options.path) - if (scope) { - params.where = { ...params.where, path: scope } - } + params.where = { + ...params.where, + path: { $startsWith: options.path } } } @@ -1781,42 +1754,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { return entity as VFSEntity } - /** - * The SERVED metadata shape for "everything under this directory". - * - * `metadata.path` is the VFS's truth — write and rename maintain it, and the - * `Contains` edges are a projection of it (see {@link repairContainment}) — - * it is indexed on every VFS entity, and the metadata index serves ordered - * range operators. So a subtree scope is a half-open range over the path - * column: O(log n + matches), no tree walk, and nothing fetched that the - * scope then discards. - * - * The range is `[dir + '/', dir + )`. Every descendant path - * begins with `dir + '/'`, and '0' is the code point directly after '/', so a - * string lies in the range EXACTLY when it carries that prefix. The two - * bounds differ at a single ASCII position, so the answer is the same under - * code-unit and code-point collation alike — no dependence on how the store - * orders the rest of the string. - * - * Sibling exclusion falls out of the same fact and is worth stating, because - * it is where a naive prefix test goes wrong: for `dir = '/scope'`, - * `/scope-sibling/x` sorts BELOW the lower bound ('-' precedes '/') and - * `/scope0` sits at the open upper bound — both outside, while - * `/scope/sub/deep/c.txt` is inside at any depth. - * - * @param path - The directory to scope to. - * @returns The `where` fragment for the `path` field, or `null` for the root - * — every VFS entity is under it, so no clause narrows the search. - */ - private descendantPathScope(path: string): { gte: string; lt: string } | null { - const dir = path.replace(/\/+/g, '/').replace(/\/$/, '') || '/' - if (dir === '/') return null - // Computed, so the bound carries its own reason: the first string that can - // no longer share the `dir + '/'` prefix. - const separatorSuccessor = String.fromCharCode('/'.charCodeAt(0) + 1) - return { gte: `${dir}/`, lt: `${dir}${separatorSuccessor}` } - } - private getParentPath(path: string): string { const normalized = path.replace(/\/+/g, '/').replace(/\/$/, '') const lastSlash = normalized.lastIndexOf('/') diff --git a/tests/configs/vitest.perf.config.ts b/tests/configs/vitest.perf.config.ts deleted file mode 100644 index 6936a71c..00000000 --- a/tests/configs/vitest.perf.config.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineConfig } from 'vitest/config' - -/** - * Perf/scale + environment-dependent test configuration. - * - * The exclusive on-demand slot for everything the correctness gate - * (`vitest.config.ts`, the config a bare `vitest run` picks up) excludes: - * wall-clock/scale benchmarks and the two tests whose outcome depends on - * the host machine or network rather than the code. See CONTRIBUTING.md's - * "Test gate" section and the exclude list in `vitest.config.ts` (root) for - * why each file lives here instead of the gate. - * - * `include` names this set explicitly — it is the mirror image of the - * root config's exclude list, not an independent glob, so the two stay in - * sync by inspection. Longer timeouts than the gate's 120s/60s: one case in - * tests/critical-performance-benchmark.test.ts measures ~128s of real work. - */ -export default defineConfig({ - test: { - globals: true, - setupFiles: ['./tests/setup.ts'], - environment: 'node', - - // The marker a test uses to tell it is running under this lane (see - // tests/integration/storage-batch-operations.test.ts's batch-vs- - // individual timing case) — a wall-clock RATIO assertion self-skips - // with a reason when this is absent, rather than flaking the - // correctness gate on whichever path happens to be faster this build. - env: { BRAINY_PERF_LANE: '1' }, - - // Sequential, single fork — same isolation the gate uses, so a perf - // measurement isn't skewed by sibling test contention. - pool: 'forks', - poolOptions: { - forks: { - maxForks: 1, - minForks: 1, - singleFork: true, - isolate: true - } - }, - - testTimeout: 300000, // 5 minutes per test (the 128s case plus headroom) - hookTimeout: 120000, - teardownTimeout: 10000, - - maxConcurrency: 1, - fileParallelism: false, - - include: [ - 'tests/performance/**/*.{test,spec}.{js,ts}', - 'tests/critical-performance-benchmark.test.ts', - 'tests/api/performance-benchmarks.test.ts', - 'tests/package-size-limit.test.ts', - 'tests/model-loading.test.ts', - // Not a whole perf file — one wall-clock-ratio case inside an - // otherwise-correctness integration suite (self-skipped everywhere - // else via BRAINY_PERF_LANE). Stays in the integration gate's - // include too, so every OTHER test in the file keeps running there. - 'tests/integration/storage-batch-operations.test.ts', - // Same pattern: one wall-clock budget case (100-file write + readdir, - // 5.5s budget) inside an otherwise-correctness VFS unit suite - // (self-skipped everywhere else via BRAINY_PERF_LANE — see - // tests/vfs/vfs.unit.test.ts's 'Performance > should handle many - // files efficiently'). Stays in the unit gate's *.unit.test.ts match - // too, so every OTHER test in the file keeps running there. - 'tests/vfs/vfs.unit.test.ts' - ], - - reporters: process.env.CI ? ['dot'] : ['basic'], - - retry: process.env.CI ? 1 : 0, - shard: process.env.VITEST_SHARD - } -}) diff --git a/tests/integration/beforeexit-never-closes.test.ts b/tests/integration/beforeexit-never-closes.test.ts deleted file mode 100644 index b7a2f95b..00000000 --- a/tests/integration/beforeexit-never-closes.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * @module tests/integration/beforeexit-never-closes - * @description A DRAINED EVENT LOOP IS NOT A SHUTDOWN. - * - * MEASURED on the 11.1 rehearsal lane, against a copy of a real store. The - * `beforeExit` listener had been wired to the SIGNAL path — the path whose job - * is to `close()` every live brain — so after the heal phase the log printed - * - * "Shutdown signal received - flushing pending data..." - * "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." - * - * Node emits `'beforeExit'` whenever the event loop has no REF'd work left. - * That is not "the process is ending" — it is a state a perfectly healthy - * script reaches, because 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. - * The engine closed a live brain out from under a running script. - * - * The contract pinned here: - * (1) `'beforeExit'` firing while a brain is open closes NOTHING: the brain - * is still open, `add()` and `find()` still work, the writer lock is - * still held, and the process still exits 0 on its own afterwards. - * (2) The pass DOES persist derived state — a non-closing `flush()` ran — - * and it wrote no clean-shutdown marker and no clean-close record: those - * are `close()`'s word about itself, and no close happened. - * (3) The signal path is untouched: SIGTERM still closes through `close()` - * (pinned by tests/integration/shutdown-single-owner.test.ts, re-run - * with this change). - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' -import { spawn } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' - -const REPO_ROOT = process.cwd() -const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx') -const BRAINY_SRC = join(REPO_ROOT, 'src', 'brainy.ts') - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)) -} - -/** The writer lock itself — present for as long as this process owns the store. */ -const writerLockPath = (dir: string) => join(dir, 'locks', '_writer.lock') -/** The clean-close record — written by `releaseWriterLock()`, i.e. by close(). */ -const closeRecordPath = (dir: string) => join(dir, 'locks', '_writer.close') -/** - * The generation store's clean-shutdown marker — written by - * `generationStore.close()` alone, reached only from `close()`. (Raw objects - * are gzipped on disk, so both spellings are accepted.) - */ -const cleanShutdownWritten = (dir: string) => - existsSync(join(dir, '_system', 'clean-shutdown.json.gz')) || - existsSync(join(dir, '_system', 'clean-shutdown.json')) - -/** - * Write a child script and run it under tsx to completion, collecting stdout - * and stderr and the exit code. (A file, not `tsx -e`: the eval form compiles - * to CommonJS, which has no top-level await.) - */ -function runChild( - scriptDir: string, - body: string -): Promise<{ code: number | null; out: string }> { - const scriptPath = join(scriptDir, 'child-process.mts') - writeFileSync(scriptPath, body) - // The child is an ORDINARY consumer process, so it runs the real embedding - // pipeline: this suite's deterministic-embedder switch is inherited through - // the environment, and under it `find()` self-retrieval returns nothing — - // which would make the read half of this pin vacuous. (That property is the - // deterministic embedder's, not this change's: it reproduces in a plain - // script with no 'beforeExit' involved.) - const env = { ...process.env } - delete env.BRAINY_DETERMINISTIC_EMBEDDINGS - const child = spawn(TSX, [scriptPath], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env - }) - let out = '' - child.stdout?.on('data', (d) => { out += String(d) }) - child.stderr?.on('data', (d) => { out += String(d) }) - return new Promise((resolvePromise) => { - child.on('exit', (code) => resolvePromise({ code, out })) - }) -} - -describe('beforeExit never closes a live brain', () => { - let dir: string - let scriptDir: string - let resultPath: string - - beforeEach(() => { - dir = makeTempDir('brainy-beforeexit-') - scriptDir = makeTempDir('brainy-beforeexit-script-') - resultPath = join(scriptDir, 'result.json') - }) - - afterEach(() => { - for (const d of [dir, scriptDir]) { - try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ } - } - }) - - it('(1)+(2) a drained event loop flushes, closes nothing, and the script keeps working', async () => { - /** - * THE DRAIN, and why the script survives it. The script awaits a promise - * that only an UNREF'd timer will resolve — the shape every engine cadence - * timer has, and the reason a healthy script reaches a loop with no ref'd - * work. Node emits `'beforeExit'` there, with the brain wide open. - * - * The engine's listener runs first (registered by `init()`, before the - * script's). The script's own listener is both its witness — it records - * that the emit happened, and the flush count AT that moment — and its - * belt: it resolves the same promise, so the pin never depends on how many - * milliseconds the engine's pass happens to keep the loop turning. - * - * The brain is DIRTY at the drain (one add, after a settling flush), so - * the pass has real work to do and pin (2) is about a flush that ran, not - * a flush that was skipped as a no-op. - */ - const script = ` - import { writeFileSync as __writeFileSync, existsSync as __existsSync } from 'node:fs' - import { join as __join } from 'node:path' - import { Brainy } from ${JSON.stringify(BRAINY_SRC)} - - const DIR = ${JSON.stringify(dir)} - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: DIR } }) - await brain.init() - - // Count every flush that RUNS on this brain. An own property shadows the - // prototype for every caller, including the engine's own listeners. - let flushes = 0 - const flushImpl = brain.flush.bind(brain) - brain.flush = () => { flushes++; return flushImpl() } - // ...and every close ENTERED. This must still be 0 after the drain. - let closes = 0 - const closeImpl = brain.close.bind(brain) - brain.close = () => { closes++; return closeImpl() } - - await brain.add({ data: 'written before the drain', type: 'concept' }) - await brain.flush() // settle: clean brain - await new Promise((r) => setTimeout(r, 250)) // let the cadence quiet down - await brain.add({ data: 'the write the drain must persist', type: 'concept' }) - - const flushesBeforeDrain = flushes - let drains = 0 - let flushesAtDrain = -1 - const drained = new Promise((resolve) => { - const t = setTimeout(resolve, 5) - if (typeof t.unref === 'function') t.unref() - process.on('beforeExit', () => { - drains++ - if (flushesAtDrain === -1) flushesAtDrain = flushes - resolve() - }) - }) - await drained - - // GIVE THE ENGINE'S PASS ITS FULL TURN before judging it. The signal - // path this listener used to share defers one macrotask before it - // touches an instance, so a script that resumes on the same tick as the - // emit would race past the damage and see an open brain that is about to - // be closed underneath it. Wait it out (a ref'd timer — the drain has - // already happened), then look. - await new Promise((r) => setTimeout(r, 1000)) - - // ---- The script is still running. The brain must still be its brain. ---- - const stateAtResume = { - drains, - flushesBeforeDrain, - flushesAtDrain, - closes, - isClosed: brain.isClosed, - isClosing: brain.isClosing, - writerLockHeld: __existsSync(__join(DIR, 'locks', '_writer.lock')), - cleanCloseRecord: __existsSync(__join(DIR, 'locks', '_writer.close')), - cleanShutdownMarker: - __existsSync(__join(DIR, '_system', 'clean-shutdown.json.gz')) || - __existsSync(__join(DIR, '_system', 'clean-shutdown.json')) - } - - let addAfterDrain = null - let addError = null - try { - addAfterDrain = await brain.add({ data: 'written AFTER the drained event loop', type: 'concept' }) - } catch (error) { - addError = error instanceof Error ? error.message : String(error) - } - - let findHits = -1 - let findError = null - try { - const results = await brain.find('written AFTER the drained event loop') - findHits = results.length - } catch (error) { - findError = error instanceof Error ? error.message : String(error) - } - - __writeFileSync( - ${JSON.stringify(resultPath)}, - JSON.stringify({ ...stateAtResume, addAfterDrain, addError, findHits, findError, closesBeforeOurs: closes }) - ) - - // The script ends the way a script ends: it closes its own brain, and - // the process exits on its own because nothing is left holding the loop. - await brain.close() - ` - - const { code, out } = await runChild(scriptDir, script) - - expect(existsSync(resultPath), `child wrote no result file:\n${out}`).toBe(true) - const r = JSON.parse(readFileSync(resultPath, 'utf-8')) - - // The drain really happened — this test proves nothing otherwise. - expect(r.drains, `'beforeExit' never fired:\n${out}`).toBeGreaterThanOrEqual(1) - - // (1) NOTHING WAS CLOSED. This is the regression: under 10.4.11 the pass - // ran close() here and `addError` carried "it was closed via close()". - expect(r.addError, `add() after the drain failed:\n${out}`).toBeNull() - expect(r.findError, `find() after the drain failed:\n${out}`).toBeNull() - expect(r.closes, 'the engine closed the brain on a drained event loop').toBe(0) - expect(r.isClosed).toBe(false) - expect(r.isClosing).toBe(false) - expect(typeof r.addAfterDrain).toBe('string') - expect(r.findHits, `find() returned nothing:\n${out}`).toBeGreaterThanOrEqual(1) - - // (1) The writer lock was never given up — a drained loop is not a handover. - expect(r.writerLockHeld, 'the writer lock was released on a drained event loop').toBe(true) - - // (2) A flush RAN, and it wrote neither of close()'s markers. - expect( - r.flushesAtDrain, - `the drained-loop pass ran no flush (before=${r.flushesBeforeDrain}):\n${out}` - ).toBeGreaterThan(r.flushesBeforeDrain) - expect(r.cleanShutdownMarker, 'the drained-loop flush stamped a clean-shutdown marker').toBe(false) - expect(r.cleanCloseRecord, 'the drained-loop flush wrote a clean-close record').toBe(false) - expect(out).toMatch(/All indexes flushed to disk/) - - // The narration says what happened, and never claims a shutdown. - expect(out).toMatch(/event loop drained with 1 brain open/) - expect(out).toMatch(/NOTHING was closed\. A drained loop is not a shutdown/) - expect(out).not.toMatch(/Shutdown signal received/) - expect(out).not.toMatch(/Flushed successfully/) - expect(out).not.toMatch(/is not initialized/) - - // (1) And the process still exits 0 on its own once the script closes up. - expect(code, `child output:\n${out}`).toBe(0) - - // The store the script left behind is clean: it closed properly at the end. - expect(cleanShutdownWritten(dir), 'the script\'s own close() wrote no marker').toBe(true) - expect(existsSync(closeRecordPath(dir)), 'the script\'s own close() left no clean-close record').toBe(true) - expect(existsSync(writerLockPath(dir)), 'the writer lock outlived close()').toBe(false) - }, 300_000) - - it('(2) the pass is repeatable and idempotent: a second drain closes nothing either', async () => { - // In-process, so the assertions are on the object itself rather than on a - // report: 'beforeExit' is an ordinary event, and emitting it twice must - // leave the brain exactly as usable as it was. - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await brain.init() - - const flushed: Promise[] = [] - const flushImpl = brain.flush.bind(brain) - ;(brain as unknown as { flush: () => Promise }).flush = () => { - const p = flushImpl() - flushed.push(p) - return p - } - - await brain.add({ data: 'a write the drained loop must persist', type: NounType.Concept }) - - for (const pass of [1, 2]) { - const before = flushed.length - process.emit('beforeExit', 0) - await Promise.all(flushed.slice(before).map((p) => p.catch(() => {}))) - // Let the pass's own `finally` run (it settles a microtask after ours), - // so the next emit is not turned away by the in-flight guard. - await new Promise((r) => setTimeout(r, 50)) - - expect(brain.isClosed, `pass ${pass} closed the brain`).toBe(false) - expect(brain.isClosing, `pass ${pass} started a close`).toBe(false) - expect(existsSync(writerLockPath(dir)), `pass ${pass} released the writer lock`).toBe(true) - expect(existsSync(closeRecordPath(dir)), `pass ${pass} wrote a clean-close record`).toBe(false) - expect(cleanShutdownWritten(dir), `pass ${pass} stamped a clean-shutdown marker`).toBe(false) - - // Still a working brain, after every pass. - const id = await brain.add({ data: `still writable after drain ${pass}`, type: NounType.Concept }) - expect(id).toBeTruthy() - } - - // The first pass had a dirty brain and flushed it; the second found it - // clean and cost nothing. Either way, neither closed anything. - expect(flushed.length).toBeGreaterThanOrEqual(2) - - await brain.close() - expect(brain.isClosed).toBe(true) - expect(cleanShutdownWritten(dir)).toBe(true) - }, 300_000) -}) diff --git a/tests/integration/find-connected-order.test.ts b/tests/integration/find-connected-order.test.ts index 3b7560e4..b04e7f99 100644 --- a/tests/integration/find-connected-order.test.ts +++ b/tests/integration/find-connected-order.test.ts @@ -67,13 +67,6 @@ describe('find({ connected }) is graph-first: neighbours → filter → page', ( }) afterAll(async () => { - // CLOSE IT. Dropping the reference does not close a brain — it only makes - // it unreachable from here. The instance stays open and registered, its - // unref'd cadence timer keeps running, and because the gate config runs the - // whole suite in ONE process (pool: 'forks', singleFork: true) it goes on - // narrating its flushes into every test file that runs after this one. - // A test that leaks a brain is a defect of the test. - await brain?.close() brain = null as any }) @@ -144,34 +137,13 @@ describe('find({ connected }) is graph-first: neighbours → filter → page', ( }) it('walks the vector leg over the neighbours only', async () => { - // The SAME query without the vector leg, first. Both legs draw from the - // one neighbour set, so this is the control: it says whether a short answer - // came from the adjacency/filter (both legs short) or from the vector walk - // alone (only the vector leg short). Cheap, and it turns a bare count - // mismatch into a named half — this case has gone red on the gate box - // while passing in isolation and beside its own predecessor, so the next - // red must arrive already carrying the half it belongs to. - const control = await brain.find({ - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note' }, - limit: 5 - }) - const results = await brain.find({ vector: sharedVector, connected: { from: anchor, direction: 'out' }, where: { kind: 'note' }, limit: 5 }) - - expect( - results.length, - `the vector leg returned ${results.length} of a requested 5. The same query ` + - `WITHOUT the vector returned ${control.length}: if that is also short the ` + - `neighbour set or the filter is the cause, and if it is 5 the vector walk is — ` + - `note every row in this corpus carries an identical vector, so the walk is ` + - `ranking an exact tie.` - ).toBe(5) + expect(results).toHaveLength(5) for (const r of results) expect(neighbourIds.has(r.entity.id)).toBe(true) }) diff --git a/tests/integration/find-fields-projection.test.ts b/tests/integration/find-fields-projection.test.ts deleted file mode 100644 index 5d339f08..00000000 --- a/tests/integration/find-fields-projection.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @module tests/integration/find-fields-projection - * @description **Field projection** — `find/get({ fields })` returns only the - * named fields, and serves them from the index when it can. - * - * A list view that shows a title and a slug does not need the document body, - * yet without a projection every row hydrates its whole record and discards - * almost all of it. These pins hold the two halves of the fix: - * - * **The answer.** A projected row is a SUBSET of the full row — for every - * requested field, the projected value equals the value the same query returns - * unprojected. Absent `fields` is byte-identical to today. A requested field the - * entity does not carry is simply absent, never an error. `system.*` resolves to - * the engine scalar, a bare name to the user's metadata. - * - * **The cost.** When every requested field is index-served, the canonical - * record is never opened — asserted by counting reads, not by timing them, so - * it cannot flake into a false green. When one requested field is NOT - * index-served (a body field, or a bucketed timestamp), exactly the owing rows - * are read and the rest are still served from the index. - */ -import { describe, it, expect, beforeAll, vi } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType } from '../../src/types/graphTypes' -import { generateTestVector } from '../helpers/test-factory' - -/** Rows carrying a title, a slug, and a large body nobody wants in a list. */ -const ROWS = 12 -const BODY = 'x'.repeat(4096) - -describe('find/get({ fields }) — projection', () => { - let brain: Brainy - const ids: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - for (let i = 0; i < ROWS; i++) { - ids.push( - await brain.add({ - id: `post-${i}`, - data: `post ${i}`, - type: NounType.Thing, - metadata: { - kind: 'post', - title: `Title ${i}`, - slug: `slug-${i}`, - rank: i, - body: BODY, - // Only some rows carry this, so "missing is absent" is exercised - // by real data rather than by a name nothing ever had. - ...(i % 2 === 0 ? { featured: true } : {}) - }, - vector: generateTestVector() - }) - ) - } - // Persist so the column store holds the values a projection reads from. - await brain.flush() - }) - - /** Count canonical record reads for one call. */ - const countingReads = async (body: () => Promise): Promise<{ out: R; reads: number }> => { - const spy = vi.spyOn(brain as any, 'batchGet') - try { - const out = await body() - const reads = spy.mock.calls.reduce( - (n, call) => n + ((call[0] as string[] | undefined)?.length ?? 0), - 0 - ) - return { out, reads } - } finally { - spy.mockRestore() - } - } - - it('absent fields is byte-identical to today', async () => { - const params = { where: { kind: 'post' }, limit: 5 } - const a = await brain.find({ ...params }) - const b = await brain.find({ ...params, fields: undefined }) - expect(JSON.stringify(b)).toBe(JSON.stringify(a)) - }) - - it('a projected row is a SUBSET of the full row, field for field', async () => { - const shapes: Array> = [ - { where: { kind: 'post' }, limit: 6 }, - { where: { kind: 'post' }, limit: 6, offset: 3 }, - { where: { kind: 'post' }, orderBy: 'rank', order: 'asc', limit: 6 }, - { where: { kind: 'post' }, orderBy: 'rank', order: 'desc', limit: 4 } - ] - for (const shape of shapes) { - const full = await brain.find(shape as never) - const projected = await brain.find({ ...shape, fields: ['title', 'slug'] } as never) - expect(projected.map((r) => r.id), JSON.stringify(shape)).toEqual(full.map((r) => r.id)) - for (let i = 0; i < full.length; i++) { - const fullMeta = (full[i].entity.metadata ?? {}) as Record - const projMeta = (projected[i].entity.metadata ?? {}) as Record - expect(projMeta.title, `${JSON.stringify(shape)} row ${i}`).toEqual(fullMeta.title) - expect(projMeta.slug).toEqual(fullMeta.slug) - } - } - }) - - it('returns ONLY the named fields — the body never rides along', async () => { - const rows = await brain.find({ where: { kind: 'post' }, fields: ['title'], limit: 4 }) - expect(rows).toHaveLength(4) - for (const r of rows) { - const meta = (r.entity.metadata ?? {}) as Record - expect(Object.keys(meta)).toEqual(['title']) - expect(meta.body).toBeUndefined() - // Identity always survives a projection: a row you cannot identify is - // not a row. - expect(typeof r.id).toBe('string') - expect(r.entity.id).toBe(r.id) - } - }) - - it('a missing field is simply ABSENT — never an error', async () => { - // `featured` exists on half the rows; `no-such-field` on none. Neither - // throws, and neither appears as an explicit undefined. - const rows = await brain.find({ - where: { kind: 'post' }, - fields: ['title', 'featured', 'no-such-field'], - limit: ROWS - }) - expect(rows.length).toBeGreaterThan(0) - let withFeatured = 0 - for (const r of rows) { - const meta = (r.entity.metadata ?? {}) as Record - expect('no-such-field' in meta).toBe(false) - if ('featured' in meta) withFeatured += 1 - } - // Real data, not a name nothing ever had: some rows carry it, some do not. - expect(withFeatured).toBeGreaterThan(0) - expect(withFeatured).toBeLessThan(rows.length) - }) - - it('a strict address resolver is NOT on this path', async () => { - // orderBy throws UnresolvableFieldError for an unknown user key, because a - // typo there silently changes the order. A projection must not inherit that - // strictness: the honest answer to "give me this if you have it" is silence. - await expect( - brain.find({ where: { kind: 'post' }, fields: ['definitely-not-a-field'], limit: 2 }) - ).resolves.toBeInstanceOf(Array) - }) - - it('system.* resolves to the engine scalar, a bare name to user metadata', async () => { - const full = await brain.find({ where: { kind: 'post' }, limit: 3 }) - const rows = await brain.find({ - where: { kind: 'post' }, - fields: ['system.createdAt', 'title'], - limit: 3 - }) - for (let i = 0; i < rows.length; i++) { - expect((rows[i].entity as any).createdAt).toEqual((full[i].entity as any).createdAt) - const meta = (rows[i].entity.metadata ?? {}) as Record - expect(meta.title).toEqual((full[i].entity.metadata as any).title) - // The engine scalar lands at the top level, not in the metadata bag — - // the two address spaces never shadow each other. - expect('system.createdAt' in meta).toBe(false) - expect('createdAt' in meta).toBe(false) - } - }) - - it('reads NO canonical record when every requested field is index-served', async () => { - // The cost pin, counted rather than timed. `title` and `slug` are ordinary - // indexed user fields, so the index can serve them exactly. - const { out, reads } = await countingReads(() => - brain.find({ where: { kind: 'post' }, fields: ['title', 'slug'], limit: ROWS }) - ) - expect(out.length).toBeGreaterThan(0) - expect(reads).toBe(0) - }) - - it('reads records only for the fields the column cannot serve', async () => { - // `system.data` is NOT a column the store holds (verified against - // getIndexedFields), so the record must be opened for it — while `title`, - // which the column does hold, still comes from the index. - const { out, reads } = await countingReads(() => - brain.find({ where: { kind: 'post' }, fields: ['title', 'system.data'], limit: 4 }) - ) - expect(out).toHaveLength(4) - expect(reads).toBe(4) - for (const r of out) { - const meta = (r.entity.metadata ?? {}) as Record - expect(Object.keys(meta)).toEqual(['title']) - expect(typeof (r.entity as any).data).toBe('string') - } - }) - - it('a large field the column DOES hold costs no record read', async () => { - // Worth pinning because it is the venue case: the body is column-served on - // this engine, so a list that projects around it pays nothing for it, and - // a list that projects it still pays no record read. - const { reads } = await countingReads(() => - brain.find({ where: { kind: 'post' }, fields: ['body'], limit: 4 }) - ) - expect(reads).toBe(0) - }) - - it('projects a vector-leg find too — the ANSWER is uniform, only the cost is not', async () => { - // The seam hydrates the metadata and graph page paths. A vector or text leg - // builds its own entities, so those rows are trimmed after the integrity - // guard instead. That difference is a COST difference, and this pin exists - // so it can never quietly become an ANSWER difference. - const rows = await brain.find({ query: 'post', fields: ['title'], limit: 3 }) - for (const r of rows) { - const meta = (r.entity.metadata ?? {}) as Record - expect(Object.keys(meta)).toEqual(['title']) - expect(meta.body).toBeUndefined() - expect(r.entity.id).toBe(r.id) - } - }) - - it('get({ fields }) projects a single row through the same seam', async () => { - const full = await brain.get(ids[0]) - const projected = await brain.get(ids[0], { fields: ['title', 'slug'] }) - expect(projected).not.toBeNull() - expect(projected!.id).toBe(full!.id) - const fullMeta = (full!.metadata ?? {}) as Record - const projMeta = (projected!.metadata ?? {}) as Record - expect(projMeta.title).toEqual(fullMeta.title) - expect(projMeta.slug).toEqual(fullMeta.slug) - expect(Object.keys(projMeta).sort()).toEqual(['slug', 'title']) - expect((projected as any).body).toBeUndefined() - }) - - it('get({ fields }) reads no record when the index serves the fields', async () => { - const { reads } = await countingReads(() => brain.get(ids[1], { fields: ['title'] })) - expect(reads).toBe(0) - }) - - it('the door serves EXACT values — the column, never the bucketed index', async () => { - // The sparse index buckets `system.createdAt` to the minute for range - // queries; the column store keeps raw ms. Serving a projection from the - // former would hand back a value that differs from the record's, so the - // door reads the column — and this pin is what proves which one it read. - const index = (brain as any).metadataIndex - const sample = ids.slice(0, 3) - const served = await index.getScalarsForIds(sample, ['title', 'system.createdAt']) - expect(served.size).toBe(sample.length) - for (const id of sample) { - const row = served.get(id)! - const record = await brain.get(id) - expect(row.title).toEqual((record!.metadata as any).title) - // Exact to the millisecond — a bucketed value would be rounded down to - // the minute and this would fail. - expect(row['system.createdAt']).toEqual((record as any).createdAt) - } - }) - - it('a field the column store does not hold is OMITTED, not approximated', async () => { - const index = (brain as any).metadataIndex - const served = await index.getScalarsForIds(ids.slice(0, 2), ['title', 'system.data']) - for (const [, row] of served) { - expect('title' in row).toBe(true) - // Omission is what makes the caller read the record for it. - expect('system.data' in row).toBe(false) - } - }) -}) diff --git a/tests/integration/find-orderby-every-path.test.ts b/tests/integration/find-orderby-every-path.test.ts deleted file mode 100644 index 7637a79b..00000000 --- a/tests/integration/find-orderby-every-path.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * @module tests/integration/find-orderby-every-path - * @description `orderBy` IS THE ORDER — on every find() path, not just the - * metadata-only one. - * - * THE DEFECT. `find({ where, orderBy })` (metadata only) answered in field - * order. `find({ query, where, orderBy })` and `find({ vector, where, orderBy })` - * answered in SCORE order, silently: the vector/filter block ranked the fused - * candidates by score, cut the page, and returned early — the tail's `orderBy` - * sort sat below that early return and never ran. Nothing threw, nothing warned, - * and the two paths disagreed about what "ordered by rank" means. A caller - * paging `orderBy: 'rank', order: 'desc'` over a hybrid find got relevance - * order wearing an ordering request's clothes. - * - * Where `connected` or `fusion` kept the tail alive the defect changed shape - * rather than disappearing: the block had already CUT the page by score, so the - * tail ordered the rows relevance had chosen instead of the rows the ordering - * asks for — a correctly sorted page of the wrong rows. - * - * The early cut fires only once the candidate set reaches `offset + limit` - * rows, which is why small fixtures never saw it: below that threshold the - * block falls through and the tail's sort does apply. That is the whole shape - * of the bug — an ordering that is correct until there is enough data to matter. - * - * THE LAW. An explicit `orderBy` displaces score as the ordering key on every - * path. The candidate set the path produced is ordered IN FULL and the page is - * cut from that ordering — the graph-first law's "page last", applied to - * ordering rather than to filtering. Score-ranked early paging is for the - * default (no `orderBy`) case only, where score IS the requested order. - * - * THE PIN. Differential, against the metadata-only path — the one path that - * always honoured `orderBy`. - * - * WHAT THE DIFFERENTIAL CAN AND CANNOT CLAIM. `orderBy` orders the candidate - * set; it does not enlarge it. The hybrid legs are bounded by construction (the - * text leg and the beam walk each take `limit * 2`), so a differential against - * the metadata-only path — whose universe is every matching row — is only - * meaningful where those bounds provably cover the universe. The fixture is - * sized so they do (12 rows, `limit` 6 → a `limit * 2` = 12-row text leg), and - * the covering is ASSERTED from the leg's own output rather than assumed. This - * pin is about ordering, and it says nothing about recall. - */ -import { describe, it, expect, beforeAll } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' -import { resolveEntityId } from '../../src/utils/idNormalization' - -/** Embedding width of the default model — the row vectors must match it. */ -const DIM = 384 - -/** A deterministic, per-row-distinct unit vector (no embedder in the fixture). */ -function seededVector(seed: number): number[] { - const v = new Array(DIM) - for (let i = 0; i < DIM; i++) { - v[i] = Math.sin((i + 1) * 0.11 + seed * 0.37) * 0.5 + Math.cos((i + 1) * 0.05 + seed * 0.13) * 0.3 - } - const magnitude = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) - return v.map((x) => x / magnitude) -} - -/** - * Ranks, shuffled — so no scoring order can reproduce them by luck, and the - * ordering the pins assert is visibly not the insertion order either. - */ -const RANKS = [7, 3, 11, 1, 9, 5, 12, 2, 10, 4, 8, 6] -const ROWS = RANKS.length -/** The page size every pin uses: `limit * 2` covers the whole universe. */ -const LIMIT = 6 -/** The neighbour subset — the graph-first universe — and its own page size. */ -const NEIGHBOURS = 8 -const GRAPH_LIMIT = 4 - -describe('find(): orderBy is the order on every path', () => { - let brain: Brainy - const QUERY = 'orbital telemetry' - const anchor = 'ordering-anchor' - const neighbourIds: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - - let seed = 1 - await brain.add({ - id: anchor, - data: 'ground station anchor record', - type: NounType.Thing, - metadata: { lane: 'anchor', rank: 0 }, - vector: seededVector(seed++) - }) - - for (let i = 0; i < ROWS; i++) { - const id = `row-${i}` - await brain.add({ - id, - // EVERY row carries both query words, so the text leg reaches all of - // them and the hybrid candidate set covers the whole universe. - data: `orbital telemetry packet ${i} recorded downlink`, - type: NounType.Document, - metadata: { lane: 'alpha', rank: RANKS[i] }, - vector: seededVector(seed++) - }) - if (i < NEIGHBOURS) { - await brain.relate({ from: anchor, to: id, type: VerbType.RelatedTo }) - neighbourIds.push(resolveEntityId(id)) - } - } - }) - - it('the fixture: the hybrid candidate set covers the whole filter universe', async () => { - const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' }) - expect(universe).toHaveLength(ROWS) - - // The text leg is bounded at `limit * 2`; the fixture is sized so that - // bound reaches every row in the universe. This is the precondition the - // differential below rests on — asserted from the leg itself. - const textScored = await (brain as any).executeTextSearchScored(QUERY, LIMIT * 2, universe) - expect(textScored).toHaveLength(ROWS) - - // And the candidate set is large enough to trigger the score-ranked early - // cut this pin exists to keep out of an ordered query's way. - expect(ROWS).toBeGreaterThanOrEqual(LIMIT) - }) - - it('metadata-only + orderBy: the reference ordering', async () => { - const rows = await brain.find({ - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: LIMIT - } as any) - expect(rows.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('hybrid (query + where) + orderBy: the same page as the metadata-only path', async () => { - const params = { where: { lane: 'alpha' }, orderBy: 'rank', order: 'desc' as const, limit: LIMIT } - const expected = await brain.find(params as any) - const actual = await brain.find({ ...params, query: QUERY } as any) - - expect(actual).toHaveLength(expected.length) - expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('hybrid + orderBy asc: the ordering key is honoured in both directions', async () => { - const params = { where: { lane: 'alpha' }, orderBy: 'rank', order: 'asc' as const, limit: LIMIT } - const expected = await brain.find(params as any) - const actual = await brain.find({ ...params, query: QUERY } as any) - - expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([1, 2, 3, 4, 5, 6]) - }) - - it('hybrid + orderBy + offset: page two is page two of the ORDERING', async () => { - const params = { - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc' as const, - limit: LIMIT, - offset: LIMIT - } - const expected = await brain.find(params as any) - const actual = await brain.find({ ...params, query: QUERY } as any) - - expect(actual).toHaveLength(LIMIT) - expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([6, 5, 4, 3, 2, 1]) - }) - - it('hybrid + orderBy: paging walks the ordering monotonically, no row twice', async () => { - const seen: number[] = [] - for (let offset = 0; offset < ROWS; offset += LIMIT) { - const page = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: LIMIT, - offset - } as any) - seen.push(...page.map((r: any) => r.metadata.rank)) - } - expect(seen).toHaveLength(ROWS) - expect(new Set(seen).size).toBe(ROWS) - // Strictly descending across every page boundary. - for (let i = 1; i < seen.length; i++) expect(seen[i]).toBeLessThan(seen[i - 1]) - }) - - it('vector + where + orderBy: field order, not distance order', async () => { - // The beam walk takes `limit * 2` = the whole universe here, so the page is - // the true top of the ordering — which distance order cannot produce. - const rows = await brain.find({ - vector: seededVector(1000), - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: LIMIT - } as any) - expect(rows.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('graph-first (query + connected + where) + orderBy: the neighbour set, ordered', async () => { - const actual = await brain.find({ - query: QUERY, - connected: { from: anchor, direction: 'out' as const }, - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: GRAPH_LIMIT - } as any) - - expect(actual).toHaveLength(GRAPH_LIMIT) - const neighbours = new Set(neighbourIds) - for (const r of actual) expect(neighbours.has(r.id)).toBe(true) - - // The ordering covers the whole neighbour set, so the page holds the - // highest ranks AMONG THE NEIGHBOURS — not the ones the score ranking - // happened to surface first and the tail then sorted among themselves. - const expectedRanks = RANKS.slice(0, NEIGHBOURS) - .sort((a, b) => b - a) - .slice(0, GRAPH_LIMIT) - expect(expectedRanks).toEqual([12, 11, 9, 7]) - expect(actual.map((r: any) => r.metadata.rank)).toEqual(expectedRanks) - }) - - it('fusion + orderBy: the ordering survives the fusion rescore', async () => { - const actual = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - fusion: 'weighted', - orderBy: 'rank', - order: 'desc', - limit: LIMIT - } as any) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('no orderBy: score order still stands (the default is untouched)', async () => { - const rows = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: LIMIT } as any) - expect(rows).toHaveLength(LIMIT) - const scores = rows.map((r: any) => r.score) - for (let i = 1; i < scores.length; i++) expect(scores[i]).toBeLessThanOrEqual(scores[i - 1]) - }) -}) diff --git a/tests/integration/graphIndex-pagination.test.ts b/tests/integration/graphIndex-pagination.test.ts index 8ad4d6d8..32a7673c 100644 --- a/tests/integration/graphIndex-pagination.test.ts +++ b/tests/integration/graphIndex-pagination.test.ts @@ -9,34 +9,9 @@ * 8.0 BigInt boundary: entity ints in (resolved via the metadata index's * idMapper), entity/verb ints out (`bigint[]`). Entity ints map back to UUIDs * via `idMapper.getUuid(Number(int))`; verb ints via `verbIntsToIds()`. - * - * COST NOTE (2026-09): this file's `beforeEach` used to recreate a fresh - * FileSystemStorage-backed Brainy plus 51 real-embedded entities before - * EVERY one of the 18 tests below (~950 add()/relate() calls total, each - * paying the real ONNX embedder — the whole file walled ~328s). Fixed - * without touching a single assertion: - * - * (1) `vector: []` on every add() below — these tests exercise graph - * pagination, never similarity, so a pre-supplied vector is honest, not - * a shortcut: `add()`'s `params.vector || (await this.embed(...))` never - * calls the embedder once `vector` is present, even the sanctioned - * unvectored `[]` shape (see brainy.ts's add(), the zero-norm-law - * comment) — and the `vector.length > 0` gate on dimension-pinning means - * `[]` never poisons `this.dimensions` for later real embeds. - * (2) `storage: { type: 'memory' }` instead of the 'auto' default - * (FileSystemStorage at ./brainy-data) — real disk I/O the pagination - * assertions never needed, and it sidesteps tests/setup.ts's global - * per-test `rm -rf brainy-data`, which would otherwise corrupt a brain - * shared across a describe's beforeAll out from under it. - * (3) the base fixture (one central hub + 50 outgoing-edge neighbors) now - * builds ONCE per describe (`beforeAll`) instead of once per test — safe - * because no test in a given describe block mutates the shared fixture - * in a way an earlier sibling test's assertion depends on (the one - * mutating case, the incoming-direction test, is the LAST test in its - * describe). */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' @@ -64,21 +39,14 @@ describe('GraphAdjacencyIndex Pagination', () => { .map((i) => idMapper().getUuid(Number(i))) .filter((u: string | undefined): u is string => u !== undefined) - /** - * Builds one central hub + 50 neighbor entities (all outgoing edges from - * the hub), unvectored and on in-memory storage (see the file header). - * Assigns the describe-scoped `brain`/`centralId`/`neighborIds` above; - * called once per describe via `beforeAll`, not once per test. - */ - async function buildFixture(): Promise { + beforeEach(async () => { brain = new Brainy({ requireSubtype: false }) - await brain.init({ storage: { type: 'memory' } }) + await brain.init() // Create central entity centralId = await brain.add({ data: { name: 'Central Hub' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) // Create 50 neighbor entities with relationships @@ -86,8 +54,7 @@ describe('GraphAdjacencyIndex Pagination', () => { for (let i = 0; i < 50; i++) { const neighborId = await brain.add({ data: { name: `Neighbor ${i}`, index: i }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) neighborIds.push(neighborId) @@ -98,14 +65,9 @@ describe('GraphAdjacencyIndex Pagination', () => { type: VerbType.RelatesTo }) } - } + }) describe('getNeighbors() Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should return all neighbors without pagination', async () => { const neighborInts = await graphIndex().getNeighbors(entityInt(centralId)) const neighbors = intsToUuids(neighborInts) @@ -187,8 +149,7 @@ describe('GraphAdjacencyIndex Pagination', () => { // Create some incoming relationships const sourceId = await brain.add({ data: { name: 'Source' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) await brain.relate({ @@ -208,11 +169,6 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('getVerbIdsBySource() Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should return all verb ints without pagination and resolve them back to ids', async () => { const verbInts: bigint[] = await graphIndex().getVerbIdsBySource(entityInt(centralId)) @@ -267,11 +223,6 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('getVerbIdsByTarget() Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should return all verb ints targeting an entity', async () => { // Pick a neighbor that's a target of relationships const targetId = neighborIds[0] @@ -285,16 +236,14 @@ describe('GraphAdjacencyIndex Pagination', () => { // Create entity with many incoming relationships const popularTarget = await brain.add({ data: { name: 'Popular Target' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) // Create 30 relationships pointing to it for (let i = 0; i < 30; i++) { const sourceId = await brain.add({ data: { name: `Source ${i}` }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) await brain.relate({ from: sourceId, @@ -318,11 +267,6 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('Performance with Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should maintain sub-5ms performance with pagination', async () => { const central = entityInt(centralId) @@ -341,17 +285,11 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('Real-World Use Cases', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should efficiently paginate through high-degree node', async () => { // Simulate popular entity with 100+ relationships const hub = await brain.add({ data: { name: 'Popular Hub' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) // Create 100 relationships @@ -359,8 +297,7 @@ describe('GraphAdjacencyIndex Pagination', () => { for (let i = 0; i < 100; i++) { const targetId = await brain.add({ data: { name: `Target ${i}` }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) targetIds.push(targetId) await brain.relate({ diff --git a/tests/integration/idle-costs-nothing.test.ts b/tests/integration/idle-costs-nothing.test.ts index 7e664a28..b5c386cf 100644 --- a/tests/integration/idle-costs-nothing.test.ts +++ b/tests/integration/idle-costs-nothing.test.ts @@ -70,22 +70,8 @@ describe('an idle brain costs nothing', () => { await brain.flush() const logged: string[] = [] - // The STACK behind each narration, kept beside the line it belongs to. - // vitest tags a stdout block with the test that is RUNNING, not the brain - // that wrote it, so teeing these lines through would only ever name this - // test. The call stack does name the driver: `kickBackgroundFlush('idle')` - // under `armIdleFlushTimer` is a cadence flush on some brain, the deferred- - // embed worker's commit path is a brain still landing vectors, and a bare - // `flush()` is an explicit caller. That distinction is the whole question. - const stacks: string[] = [] const origLog = console.log - console.log = ((...a: unknown[]) => { - const line = a.map(String).join(' ') - logged.push(line) - if (/All indexes flushed to disk|Flushing Brainy indexes/.test(line)) { - stacks.push(new Error('flush narration').stack ?? '(no stack)') - } - }) as typeof console.log + console.log = ((...a: unknown[]) => { logged.push(a.map(String).join(' ')) }) as typeof console.log // Watch the providers directly: a flush that runs calls all of them. const storage = (brain as unknown as { storage: { flushCounts: () => Promise } }).storage @@ -102,38 +88,11 @@ describe('an idle brain costs nothing', () => { } // (a) + (b): nothing ran, nothing was said. - // - // THE SPIES COME FIRST, AND THEY ARE THE ATTRIBUTABLE HALF. They are bound - // to THIS brain's providers, so they answer "did this brain flush?" and - // nothing else. The console filters below cannot: the gate config runs the - // whole suite in ONE process (`pool: 'forks'`, `singleFork: true`), so - // `console.log` carries the narration of every brain alive in that - // process — including one a previous file opened and never closed, whose - // unref'd cadence timer is still doing honest work. A neighbour narrating - // is a REAL finding about suite hygiene, but it is not this brain failing - // its own law, and the two must not be reported as the same thing. - // - // So: spies first (whose failure means the engine broke the law), console - // second (whose failure means SOMETHING in the process narrated), and the - // console assertion carries the captured lines in its message. vitest's - // stdout blocks are prefixed `stdout | > `, so those lines - // plus the surrounding gate log name the brain that printed them. + expect(logged.filter((l) => /All indexes flushed to disk/.test(l))).toEqual([]) + expect(logged.filter((l) => /Flushing Brainy indexes/.test(l))).toEqual([]) expect(countsSpy).not.toHaveBeenCalled() expect(metadataSpy).not.toHaveBeenCalled() expect(graphSpy).not.toHaveBeenCalled() - - const flushChatter = logged.filter( - (l) => /All indexes flushed to disk/.test(l) || /Flushing Brainy indexes/.test(l) - ) - expect( - flushChatter, - `${flushChatter.length} flush line(s) narrated during the ${IDLE_WATCH_MS}ms idle ` + - `window. This brain's own providers were NOT called (asserted above), so another ` + - `brain alive in this process printed them — the suite runs every file in ONE ` + - `process and 67 test files create more brains than they close.\n` + - `${flushChatter.join('\n')}\n\n` + - `The stack behind the first one names the driver:\n${stacks[0] ?? '(none captured)'}` - ).toEqual([]) }, 180_000) it('an explicit flush over a clean brain calls no provider and prints nothing', async () => { diff --git a/tests/integration/metadata-vector-exclusion.test.ts b/tests/integration/metadata-vector-exclusion.test.ts index 1943b215..9e11f9dc 100644 --- a/tests/integration/metadata-vector-exclusion.test.ts +++ b/tests/integration/metadata-vector-exclusion.test.ts @@ -26,7 +26,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' import { existsSync, rmSync } from 'fs' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../src/errors/brainyError.js' describe('Metadata Vector Exclusion Fix', () => { let brainy: Brainy @@ -156,57 +155,29 @@ describe('Metadata Vector Exclusion Fix', () => { expect(results[0].entity.metadata?.name).toBe('Bob') }) - it('should REFUSE an array over the indexing bound, by name', async () => { - // A large array (not a vector, just bulk data). This used to be SKIPPED in - // silence at a bound of 10 — the field simply vanished from the index and - // the row dropped out of every `where` on it, indistinguishably from "no - // row matches". The bound is now MAX_INDEXED_ARRAY_LENGTH and it REFUSES. - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - const largeArray = Array.from({ length: overTheBound }, (_, i) => `item${i}`) + it('should skip indexing large arrays (>10 elements)', async () => { + // Add entity with a large array (not a vector, just bulk data). + const largeArray = Array.from({ length: 100 }, (_, i) => `item${i}`) - const err = await brainy - .add({ - type: NounType.Document, - data: 'Doc with large array', - metadata: { - name: 'Doc with large array', - items: largeArray - } - }) - .catch((e: any) => e) + await brainy.add({ + type: NounType.Document, + data: 'Doc with large array', + metadata: { + name: 'Doc with large array', + items: largeArray + } + }) - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('items') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - - // Nothing was indexed from the refused write — no 'items' field, and above - // all no per-element numeric fields (the original explosion class). + // Large arrays (> 10 elements) are deliberately skipped to avoid indexing + // bulk/vector-like payloads: 'items' must NOT appear, and the 100 elements + // must NOT have produced 100 indexed fields. const fields = await brainy.getAvailableFields() expect(fields).not.toContain('items') const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f)) expect(numericFields).toEqual([]) - }) - it('should index an array UP TO the bound — the old limit of 10 was the bug', async () => { - await brainy.add({ - type: NounType.Document, - data: 'Doc with a long-but-legitimate tag list', - metadata: { - name: 'Doc with many tags', - items: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`) - } - }) - - const fields = await brainy.getAvailableFields() - // The field IS indexed now, and still without per-element numeric fields. - expect(fields).toContain('items') - expect(fields.filter(f => /(^|\.)\d+$/.test(f))).toEqual([]) - - // And the eleventh element — the one the old bound silently dropped the - // whole field for — really is searchable. - const hits = await brainy.find({ where: { items: 'item10' } }) - expect(hits.length).toBeGreaterThan(0) + // The scalar 'name' field IS indexed. + expect(fields).toContain('name') }) it('should preserve HNSW vector search functionality', async () => { diff --git a/tests/integration/pending-embed-checkpoint.test.ts b/tests/integration/pending-embed-checkpoint.test.ts deleted file mode 100644 index 1cf3ec2c..00000000 --- a/tests/integration/pending-embed-checkpoint.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -/** - * @module tests/integration/pending-embed-checkpoint - * @description THE PENDING-EMBED CHECKPOINT — the bound that engages on the - * brains that need it. - * - * 10.4.9 bounded the open-path `recover-pending-embeds` fold with a LOW-WATER - * MARK: the log head at which the pending set last drained to EMPTY. That mark - * carries no set, so it can only be written when the set is empty — and a brain - * holding even ONE id that never lands (an embed that keeps failing, a worker - * that never gets to it, a row reaped in memory only and re-folded every open) - * never drains, therefore never writes a mark, therefore re-reads its WHOLE - * fact log on every single open. The bound was absent from exactly the brains - * whose fold is expensive: a silent scaling defect. - * - * The cure is a CHECKPOINT of the pending set — - * `_system/pending_embeds_checkpoint.json` = `{ generation, pending, writtenAt }`, - * meaning "as of durable generation G the pending set was exactly this list". - * Open seeds the set from `pending` and scans only from `G + 1`, so the fold is - * O(facts since G) whether or not the set ever drains. - * - * What this suite pins: - * 1. A brain with one permanently-stuck pending id, closed cleanly and - * reopened, scans ONLY the facts after the checkpoint — asserted from the - * fold's own accounting, never a clock. The same fixture pins the DEFECT: - * no low-water mark exists on that brain, because it never drained. - * 2. A crash matrix in a REAL child process (SIGKILL, no close), for kills - * before a checkpoint write, after one with embeds landed and flushed - * after it, and after one with an UN-FLUSHED tail at the moment of death. - * The invariant in every row is differential: the checkpoint-bounded fold - * the reopened brain actually ran ≡ a full fold from generation 1 over the - * same recovered log. - * 3. A torn checkpoint falls back — loudly (the adapter's torn-record gauge - * plus the fold's own narration of which bound applied) and correctly. - * 4. The existing low-water pins keep passing unchanged - * (`pending-embed-low-water.test.ts`): the mark is still written and is - * still read, now as the FALLBACK bound beneath the checkpoint. - * - * The crash-recovery contract is untouched: the fold runs on the open's - * foreground, so a reopened brain has its markers re-armed when open() returns. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' -import { spawn } from 'node:child_process' -import { gunzipSync } from 'node:zlib' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { getTornRecordGauge } from '../../src/storage/tornRecordError.js' - -const CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json' -const LOWWATER_PATH = '_system/pending_embeds_lowwater.json' -const REPO_ROOT = process.cwd() -const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx') - -/** The fold's own accounting for the most recent open. */ -interface FoldReport { - bound: 'checkpoint' | 'low-water' | 'genesis' - fromGeneration: number - factsScanned: number - seeded: number - pending: number -} - -const roots: string[] = [] -const liveBrains: Brainy[] = [] - -function dir(): string { - const d = mkdtempSync(join(tmpdir(), 'brainy-embed-ckpt-')) - roots.push(d) - return d -} - -async function open(root: string, opts?: { blockWorker?: boolean }): Promise> { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: root } - }) - // Blocking the worker BEFORE init() is how a "permanently stuck" pending id - // is built deterministically: the state under test is "an id the fold keeps - // re-arming and nothing ever disarms", and its production causes (a failing - // embedder, a wedged model, a data-less row) all reduce to exactly that. - if (opts?.blockWorker) (brain as unknown as { kickEmbedWorker: () => void }).kickEmbedWorker = () => {} - await brain.init() - liveBrains.push(brain) - return brain -} - -function foldReport(brain: Brainy): FoldReport { - const report = (brain as unknown as { _pendingEmbedFoldReport: FoldReport | null }) - ._pendingEmbedFoldReport - if (report === null) throw new Error('the open ran no pending-embed fold') - return report -} - -function pendingIds(brain: Brainy): string[] { - return [ - ...(brain as unknown as { _pendingEmbedIds: Set })._pendingEmbedIds - ].sort() -} - -/** Read an artifact straight off disk (the adapter gzips raw objects). */ -function readArtifact(root: string, path: string): Record | null { - const plain = join(root, ...path.split('/')) - const gz = `${plain}.gz` - if (existsSync(gz)) return JSON.parse(gunzipSync(readFileSync(gz)).toString('utf-8')) - if (existsSync(plain)) return JSON.parse(readFileSync(plain, 'utf-8')) - return null -} - -/** The on-disk path the adapter actually used for an artifact. */ -function artifactPath(root: string, path: string): string | null { - const plain = join(root, ...path.split('/')) - const gz = `${plain}.gz` - if (existsSync(gz)) return gz - if (existsSync(plain)) return plain - return null -} - -/** - * THE DIFFERENTIAL ORACLE: fold the log from generation 1 with exactly the - * engine's own rules. This is what the bounded fold must agree with, and its - * fact count is what the unbounded fold used to read at every open. - */ -async function fullFold(brain: Brainy): Promise<{ ids: string[]; facts: number }> { - const log = ( - brain as unknown as { generationStore: { getFactLog(): any } } - ).generationStore.getFactLog() - const pending = new Set() - let facts = 0 - const scan = log.scanFacts({ fromGeneration: 1 }) - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - facts++ - for (const record of fact.records ?? []) { - if (record.type === 'embed.pending') pending.add(record.id) - else if (record.type === 'embed.landed') pending.delete(record.id) - } - for (const op of fact.ops) { - if (op.kind === 'noun' && op.record === null) pending.delete(op.id) - } - } - } - return { ids: [...pending].sort(), facts } -} - -/** Capture every console.warn/error line emitted while `fn` runs. */ -async function captureConsole(fn: () => Promise): Promise<{ result: T; lines: string[] }> { - const lines: string[] = [] - const origWarn = console.warn - const origError = console.error - const sink = (...args: unknown[]) => { - lines.push(args.map((a) => String(a)).join(' ')) - } - console.warn = sink as typeof console.warn - console.error = sink as typeof console.error - try { - const result = await fn() - return { result, lines } - } finally { - console.warn = origWarn - console.error = origError - } -} - -/** - * Run a child process that arranges a store and then waits forever, so the - * parent can SIGKILL it. A real process death is the only honest way to pin - * "no close ran, no shutdown hook ran, RAM is gone". - * - * `detached` puts the child in its own process GROUP: tsx runs the script in a - * grandchild, and only a group-wide signal reaches the process holding the - * writer lock. - */ -function spawnArranger(root: string, body: string): Promise<{ - child: ReturnType - output: () => string -}> { - const scriptPath = join(root, 'arrange.mts') - writeFileSync(scriptPath, body) - const child = spawn(TSX, [scriptPath], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true - }) - let out = '' - child.stdout!.on('data', (d) => { out += String(d) }) - child.stderr!.on('data', (d) => { out += String(d) }) - return new Promise((resolvePromise, rejectPromise) => { - const timer = setTimeout( - () => rejectPromise(new Error(`arranger never became READY:\n${out}`)), - 180_000 - ) - child.stdout!.on('data', () => { - if (out.includes('READY')) { - clearTimeout(timer) - resolvePromise({ child, output: () => out }) - } - }) - child.on('exit', (code) => { - clearTimeout(timer) - if (!out.includes('READY')) rejectPromise(new Error(`arranger exited ${code}:\n${out}`)) - }) - }) -} - -/** Parse the `IDS:{...}` line an arranger prints — supplied ids are normalised - * to canonical uuids, and the markers, checkpoint and fold all speak those. */ -function childIds(output: string): Record { - const line = output.split('\n').find((l) => l.startsWith('IDS:')) - if (!line) throw new Error(`arranger printed no IDS line:\n${output}`) - return JSON.parse(line.slice('IDS:'.length)) -} - -/** SIGKILL the whole group and wait for the grandchild's death to settle. */ -async function sigkill(child: ReturnType): Promise { - process.kill(-(child.pid as number), 'SIGKILL') - await new Promise((r) => child.on('exit', () => r())) - await new Promise((r) => setTimeout(r, 500)) -} - -/** The preamble every arranger child shares. */ -function childPreamble(root: string): string { - return ` - import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))} - const ROOT = ${JSON.stringify(root)} - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ROOT } }) - const block = () => { (brain as any).kickEmbedWorker = () => {} } - const settleCheckpoint = async () => { - // The cadence write is fire-and-forget; wait for the single flight. - for (let i = 0; i < 200; i++) { - if (!(brain as any)._pendingEmbedCheckpointFlight) break - await (brain as any)._pendingEmbedCheckpointFlight.catch(() => {}) - } - } - ` -} - -afterEach(async () => { - for (const brain of liveBrains.splice(0)) { - try { await brain.close() } catch { /* already closed / crashed — teardown only */ } - } - for (const d of roots.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -// =========================================================================== -// 1. The stuck-id brain — the defect, and the bound that now engages on it -// =========================================================================== - -describe('pending-embed checkpoint — a brain whose pending set never drains', () => { - it('a permanently-stuck pending id: the reopen scans only the facts after the checkpoint', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - // add() returns the CANONICAL id (supplied ids are normalised), and that is - // the id the markers, the checkpoint and the fold all speak. - const stuck = await first.add({ - id: 'stuck', - data: 'a deferred row whose embed never lands', - type: NounType.Thing, - deferEmbedding: true - }) - expect(first.pendingEmbedCount()).toBe(1) - // Ordinary traffic after it — every one of these is a fact the unbounded - // fold had to re-read at every open, forever, because of that one id. - for (let i = 0; i < 12; i++) { - await first.add({ id: `row-${i}`, data: `row ${i}`, type: NounType.Thing }) - } - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - // THE DEFECT, PINNED: the pending set never drained, so the old bound was - // never written — nothing on this brain could have shortened its fold. - expect(readArtifact(root, LOWWATER_PATH)).toBeNull() - // The checkpoint IS written at the clean close, set non-empty and all. - const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { - generation: number - pending: string[] - } | null - expect(checkpoint).not.toBeNull() - expect(checkpoint!.generation).toBeGreaterThan(0) - expect(checkpoint!.pending).toEqual([stuck]) - - const second = await open(root, { blockWorker: true }) - const report = foldReport(second) - // THE FIX, from the fold's own counter — not the clock. - expect(report.bound).toBe('checkpoint') - expect(report.fromGeneration).toBe(checkpoint!.generation + 1) - expect(report.factsScanned).toBe(0) - expect(report.seeded).toBe(1) - // The crash-recovery contract is intact: the marker is re-armed by open(). - expect(pendingIds(second)).toEqual([stuck]) - expect(second.pendingEmbedCount()).toBe(1) - - // The differential: the bounded answer is the full-fold answer, and the - // full fold is what the previous bound would have had to read. - const full = await fullFold(second) - expect(full.ids).toEqual([stuck]) - expect(full.facts).toBeGreaterThanOrEqual(13) - expect(report.factsScanned).toBeLessThan(full.facts) - }, 180_000) - - it('the bound stays O(delta) across repeated opens while the id is still stuck', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - const stuck = await first.add({ - id: 'stuck', - data: 'never lands', - type: NounType.Thing, - deferEmbedding: true - }) - for (let i = 0; i < 6; i++) { - await first.add({ id: `a-${i}`, data: `a ${i}`, type: NounType.Thing }) - } - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - const second = await open(root, { blockWorker: true }) - expect(foldReport(second).factsScanned).toBe(0) - // More history under the same stuck id. - for (let i = 0; i < 9; i++) { - await second.add({ id: `b-${i}`, data: `b ${i}`, type: NounType.Thing }) - } - await second.close() - liveBrains.splice(liveBrains.indexOf(second), 1) - - const third = await open(root, { blockWorker: true }) - const report = foldReport(third) - const full = await fullFold(third) - expect(report.bound).toBe('checkpoint') - expect(report.factsScanned).toBe(0) - // The unbounded fold grew with the store; the bounded one did not. - expect(full.facts).toBeGreaterThanOrEqual(16) - expect(pendingIds(third)).toEqual([stuck]) - expect(full.ids).toEqual([stuck]) - }, 180_000) -}) - -// =========================================================================== -// 2. Torn checkpoint — falls back, loudly, correctly -// =========================================================================== - -describe('pending-embed checkpoint — a torn checkpoint never shortens the fold', () => { - it('an undecodable checkpoint file degrades to the next bound, loudly, with the right pending set', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - const stuck = await first.add({ - id: 'stuck', - data: 'never lands', - type: NounType.Thing, - deferEmbedding: true - }) - for (let i = 0; i < 5; i++) { - await first.add({ id: `row-${i}`, data: `row ${i}`, type: NounType.Thing }) - } - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - const onDisk = artifactPath(root, CHECKPOINT_PATH) - expect(onDisk).not.toBeNull() - // Tear it: bytes that are neither valid gzip nor valid JSON. A torn file - // must THROW on read — never parse into a partial `pending` list. - writeFileSync(onDisk!, 'not a checkpoint at all {{{') - - const before = getTornRecordGauge().count - const { result: second, lines } = await captureConsole(async () => - open(root, { blockWorker: true }) - ) - const report = foldReport(second) - // Fell back — never to a shorter bound, and never silently. - expect(report.bound).not.toBe('checkpoint') - expect(report.seeded).toBe(0) - expect(report.fromGeneration).toBe(1) // no mark either: this brain never drained - // LOUD, two ways: the adapter's torn-record gauge and its production error… - expect(getTornRecordGauge().count).toBeGreaterThan(before) - expect(getTornRecordGauge().lastPath).toContain('pending_embeds_checkpoint') - expect(lines.some((l) => /TORN RECORD/.test(l))).toBe(true) - // …and the fold's own narration of which bound it actually used. - expect(lines.some((l) => /pending-embed fold: genesis bound/.test(l))).toBe(true) - - // CORRECT: the marker is still recovered, from the log itself. - expect(pendingIds(second)).toEqual([stuck]) - const full = await fullFold(second) - expect(full.ids).toEqual([stuck]) - expect(report.factsScanned).toBe(full.facts) - }, 180_000) - - it('a well-formed but shape-invalid checkpoint is refused whole, never partially trusted', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - const stuck = await first.add({ - id: 'stuck', - data: 'never lands', - type: NounType.Thing, - deferEmbedding: true - }) - await first.add({ id: 'other', data: 'ordinary row', type: NounType.Thing }) - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - // A checkpoint with a plausible generation but a `pending` that is not a - // list of ids: trusting the generation alone would bound the scan behind a - // set that was never recovered — the exact shape that loses a vector. - const onDisk = artifactPath(root, CHECKPOINT_PATH)! - const good = readArtifact(root, CHECKPOINT_PATH) as { generation: number } - rmSync(onDisk) - writeFileSync( - join(root, '_system', 'pending_embeds_checkpoint.json'), - JSON.stringify({ generation: good.generation, pending: { stuck: true }, writtenAt: 1 }) - ) - - const { result: second, lines } = await captureConsole(async () => - open(root, { blockWorker: true }) - ) - expect(lines.some((l) => /pending-embed checkpoint REFUSED/.test(l))).toBe(true) - const report = foldReport(second) - expect(report.bound).not.toBe('checkpoint') - expect(report.seeded).toBe(0) - expect(pendingIds(second)).toEqual([stuck]) - }, 180_000) -}) - -// =========================================================================== -// 3. The crash matrix — real processes, real SIGKILL, differential invariant -// =========================================================================== - -describe('pending-embed checkpoint — crash matrix (real child process, SIGKILL)', () => { - /** - * The invariant every row shares: whatever the reopened brain's fold did with - * whatever bound survived the crash, its pending set must equal the truth a - * full fold from generation 1 derives from the SAME recovered log. - */ - async function assertDifferentialAfterCrash(root: string): Promise<{ - report: FoldReport - full: { ids: string[]; facts: number } - pending: string[] - }> { - const reopened = await open(root, { blockWorker: true }) - const report = foldReport(reopened) - const full = await fullFold(reopened) - const pending = pendingIds(reopened) - expect(pending).toEqual(full.ids) - return { report, full, pending } - } - - it('killed BEFORE any checkpoint was written — falls back and recovers the marker from the log', async () => { - const root = dir() - const { child, output } = await spawnArranger( - root, - `${childPreamble(root)} - block() - await brain.init() - await brain.add({ id: 'landed-row', data: 'an ordinary row', type: 'thing' }) - const stuck = await brain.add({ id: 'stuck-1', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) - await brain.flush() - console.log('IDS:' + JSON.stringify({ stuck })) - console.log('READY') - setInterval(() => {}, 1000) - ` - ) - const ids = childIds(output()) - // One enqueue is well under the cadence and the set never drained, so no - // checkpoint exists — this is the pre-checkpoint crash. - expect(readArtifact(root, CHECKPOINT_PATH)).toBeNull() - await sigkill(child) - - const { report, pending } = await assertDifferentialAfterCrash(root) - expect(report.bound).toBe('genesis') - expect(pending).toEqual([ids.stuck]) - }, 300_000) - - it('killed AFTER a checkpoint, with an embed landed and flushed after it — the post-checkpoint facts carry the disarm', async () => { - const root = dir() - const { child, output } = await spawnArranger( - root, - `${childPreamble(root)} - await brain.init() - // Land one deferred embed: the drain arms the checkpoint debt. - await brain.add({ id: 'seed', data: 'lands first', type: 'thing', deferEmbedding: true }) - await brain.awaitPendingEmbeds() - await brain.flush() - // A second deferred write pays the debt (the head is at the manifest now), - // then LANDS — its embed.landed rides a fact ABOVE the checkpoint. - const landsAfter = await brain.add({ id: 'lands-after', data: 'lands after the checkpoint', type: 'thing', deferEmbedding: true }) - await settleCheckpoint() - await brain.awaitPendingEmbeds() - // …and one that never will. - block() - const stuck = await brain.add({ id: 'stuck-1', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) - await brain.add({ id: 'plain', data: 'more history', type: 'thing' }) - await brain.flush() - console.log('IDS:' + JSON.stringify({ stuck, landsAfter })) - console.log('READY') - setInterval(() => {}, 1000) - ` - ) - const ids = childIds(output()) - const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { - generation: number - pending: string[] - } | null - expect(checkpoint).not.toBeNull() - await sigkill(child) - - const { report, full, pending } = await assertDifferentialAfterCrash(root) - expect(report.bound).toBe('checkpoint') - expect(report.fromGeneration).toBe(checkpoint!.generation + 1) - // The bound really bounded: fewer facts than the whole log. - expect(report.factsScanned).toBeLessThan(full.facts) - // A landed embed above the checkpoint is disarmed by the scan, not lost; - // the stuck one is re-armed. - expect(pending).toEqual([ids.stuck]) - expect(pending).not.toContain(ids.landsAfter) - }, 300_000) - - it('killed AFTER a checkpoint with an UN-FLUSHED tail — truncated facts and the bounded fold still agree', async () => { - const root = dir() - const { child } = await spawnArranger( - root, - `${childPreamble(root)} - await brain.init() - await brain.add({ id: 'seed', data: 'lands first', type: 'thing', deferEmbedding: true }) - await brain.awaitPendingEmbeds() - await brain.flush() - await brain.add({ id: 'lands-after', data: 'lands after the checkpoint', type: 'thing', deferEmbedding: true }) - await settleCheckpoint() - await brain.awaitPendingEmbeds() - await brain.flush() - // Now write PAST the manifest and never flush: these facts are the tail a - // crash truncates. Whatever survives, the two folds must agree on it. - block() - await brain.add({ id: 'stuck-tail', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) - await brain.add({ id: 'plain-tail', data: 'unflushed history', type: 'thing' }) - console.log('READY') - setInterval(() => {}, 1000) - ` - ) - const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { generation: number } | null - expect(checkpoint).not.toBeNull() - await sigkill(child) - - const { report } = await assertDifferentialAfterCrash(root) - // The checkpoint's generation is at or below the manifest by construction, - // so it survived the truncation and still bounds the fold. - expect(report.bound).toBe('checkpoint') - expect(report.fromGeneration).toBe(checkpoint!.generation + 1) - }, 300_000) -}) diff --git a/tests/integration/readonly-close-no-marker.test.ts b/tests/integration/readonly-close-no-marker.test.ts deleted file mode 100644 index ad9357db..00000000 --- a/tests/integration/readonly-close-no-marker.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * @module tests/integration/readonly-close-no-marker - * @description A READ-ONLY BRAIN WRITES NO CLEAN-SHUTDOWN EVIDENCE. - * - * `_system/clean-shutdown.json` is the WRITER's own word about the writer's - * own process: "everything above this line, from THIS session, is durable." - * Two call sites treated a reader exactly like a writer: - * - * 1. `Brainy#closeDurableSteps()` called `generationStore.close()` - * unconditionally — a reader's close re-stamped the marker at the - * generation the reader merely OBSERVED, never committed. - * 2. `GenerationStore#open()` consumed (deleted) the marker on every open, - * reader or writer alike, so a reader that never got to a matching - * close left the store looking crashed to the next writer. - * - * Both are fixed by making a read-only brain leave `_system/` exactly as it - * found it — at open AND at close. Pinned here: - * - * 1. `_system/` is byte-for-byte identical (file set + contents) before and - * after a reader opens a cleanly-closed store, reads it, and closes. - * 2. After the reader's close, the next WRITER open adopts the marker as - * clean — no recovery fold narrates. - * 3. A reader creates no file under `_system/` merely by opening (before it - * ever closes). - * 4. A reader that opens and is then abandoned (crash-style, no close) does - * not force the next writer to pay a recovery fold — the concrete harm - * the fix closes. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { abandonAsCrashed } from '../helpers/durabilityKillMatrix.js' - -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), 'brainy-readonly-close-')) -} - -/** Recursively hash every regular file under `dir`, keyed by its path relative to `dir`. */ -function snapshotDir(dir: string): Map { - const out = new Map() - const walk = (rel: string): void => { - const abs = rel ? join(dir, rel) : dir - let entries: string[] - try { - entries = readdirSync(abs) - } catch { - return - } - for (const name of entries) { - const childRel = rel ? join(rel, name) : name - const childAbs = join(dir, childRel) - const st = statSync(childAbs) - if (st.isDirectory()) { - walk(childRel) - } else if (st.isFile()) { - const hash = createHash('sha256').update(readFileSync(childAbs)).digest('hex') - out.set(childRel, hash) - } - } - } - walk('') - return out -} - -/** Capture console.warn lines (the narration channel — see `prodLog.narrate`) while `fn` runs. */ -async function captureWarn(fn: () => Promise): Promise<{ result: T; lines: string[] }> { - const lines: string[] = [] - const orig = console.warn - console.warn = ((...args: unknown[]) => { - lines.push(args.map((a) => String(a)).join(' ')) - }) as typeof console.warn - try { - return { result: await fn(), lines } - } finally { - console.warn = orig - } -} - -describe('a read-only brain writes no clean-shutdown evidence', () => { - let dir: string - let brain: Brainy | null = null - - beforeEach(() => { - dir = makeTempDir() - }) - - afterEach(async () => { - if (brain) { - try { - await brain.close() - } catch { - /* already closed */ - } - brain = null - } - try { - rmSync(dir, { recursive: true, force: true }) - } catch { - /* ignore */ - } - }) - - const systemDir = () => join(dir, '_system') - /** - * The marker file's actual on-disk name — `clean-shutdown.json` or, under - * FileSystemStorage's default gzip compression, `clean-shutdown.json.gz`. - * Returns null when absent. - */ - const findMarkerPath = (): string | null => { - let entries: string[] - try { - entries = readdirSync(systemDir()) - } catch { - return null - } - const name = entries.find((n) => n.startsWith('clean-shutdown.json')) - return name ? join(systemDir(), name) : null - } - - it('leaves `_system/`\'s file set and the clean-shutdown marker\'s bytes identical across a reader open → read → close', async () => { - // A writer opens, writes, and closes cleanly — the marker lands at - // whatever generation the writer actually committed. - const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer.init() - await writer.add({ data: 'seed entity', type: NounType.Concept }) - await writer.add({ data: 'second entity', type: NounType.Concept }) - await writer.flush() - await writer.close() - - const markerBeforePath = findMarkerPath() - expect(markerBeforePath, 'the writer left a clean-shutdown marker').not.toBeNull() - const before = snapshotDir(systemDir()) - expect(before.size).toBeGreaterThan(0) - const markerBeforeHash = before.get( - (markerBeforePath as string).slice(systemDir().length + 1) - ) - expect(markerBeforeHash).toBeTruthy() - - // A reader opens the same store, reads, and closes. - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - expect(brain.isReadOnly).toBe(true) - await brain.stats() - await brain.close() - brain = null - - // The FILE SET under `_system/` is unchanged — a reader creates and - // removes nothing. This pin is specifically about the generation store's - // clean-shutdown evidence. The wider law — that a reader leaves EVERY - // file under `_system/` byte-identical, which this fix left open as a - // known residual (the metadata field registry and the three statistics - // files were still re-stamped by a reader's close) — is closed and pinned - // in `readonly-close-writes-nothing.test.ts`. - const after = snapshotDir(systemDir()) - expect([...after.keys()].sort()).toEqual([...before.keys()].sort()) - - // The MARKER's bytes are byte-for-byte identical — the reader neither - // consumed it at open nor re-stamped it at close. - const markerAfterPath = findMarkerPath() - expect(markerAfterPath, 'the marker must still exist, under the same name').toBe(markerBeforePath) - const markerAfterHash = after.get((markerAfterPath as string).slice(systemDir().length + 1)) - expect(markerAfterHash).toBe(markerBeforeHash) - }, 120_000) - - it('creates no file under `_system/` merely by opening read-only', async () => { - const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer.init() - await writer.add({ data: 'seed entity', type: NounType.Concept }) - await writer.flush() - await writer.close() - - const baselineNames = [...snapshotDir(systemDir()).keys()].sort() - expect(baselineNames.length).toBeGreaterThan(0) - - // Open the reader and inspect `_system/` BEFORE it ever closes — open() - // alone must create nothing. - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - const whileOpenNames = [...snapshotDir(systemDir()).keys()].sort() - expect(whileOpenNames).toEqual(baselineNames) - - await brain.close() - brain = null - }, 120_000) - - it('a writer reopening after the reader closes adopts the marker — no recovery fold', async () => { - const writer1 = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer1.init() - await writer1.add({ data: 'seed entity', type: NounType.Concept }) - await writer1.flush() - await writer1.close() - - // A reader opens and closes in between — must not disturb the marker. - const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await reader.stats() - await reader.close() - - // The next writer open must be a clean, no-fold open: no - // "log-authority recovery" / "WHOLE-LOG fold" narration line. - const { result: writer2, lines } = await captureWarn(async () => { - const w = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await w.init() - return w - }) - brain = writer2 - - const foldLines = lines.filter((l) => /log-authority recovery|WHOLE-LOG fold|recovery fold/i.test(l)) - expect(foldLines, `unexpected recovery narration:\n${foldLines.join('\n')}`).toEqual([]) - - // And the store is exactly what the first writer left — the seed row is - // still there, nothing was rolled back or re-derived. - const found = await writer2.find({ where: {} } as any) - expect(found.length).toBeGreaterThanOrEqual(1) - }, 120_000) - - it('a reader that opens and is then abandoned (never closes) does not force the next writer to fold', async () => { - // This is the concrete harm the fix closes: pre-fix, a reader's open() - // unconditionally DELETED the marker (consuming it as if it were the - // writer). A reader that opened and then died — no close, exactly like - // a killed process — left the marker gone, so the actual writer's next - // open read the store as crashed and paid a full recovery fold for a - // "crash" that was really just a reader that came and went. - const writer1 = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer1.init() - await writer1.add({ data: 'seed entity', type: NounType.Concept }) - await writer1.flush() - await writer1.close() - - const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await reader.stats() - // NEVER calls reader.close() — abandon it exactly like a killed process. - await abandonAsCrashed(reader) - - const { result: writer2, lines } = await captureWarn(async () => { - const w = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await w.init() - return w - }) - brain = writer2 - - const foldLines = lines.filter((l) => /log-authority recovery|WHOLE-LOG fold|recovery fold/i.test(l)) - expect( - foldLines, - `an abandoned READER forced a recovery fold on the next writer open:\n${foldLines.join('\n')}` - ).toEqual([]) - }, 120_000) -}) diff --git a/tests/integration/readonly-close-writes-nothing.test.ts b/tests/integration/readonly-close-writes-nothing.test.ts deleted file mode 100644 index 701a1974..00000000 --- a/tests/integration/readonly-close-writes-nothing.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @module tests/integration/readonly-close-writes-nothing - * @description A READ-ONLY BRAIN LEAVES `_system/` BYTE-IDENTICAL — the WHOLE - * directory, not just the clean-shutdown marker. - * - * `readonly-close-no-marker` closed the marker half of this law and named the - * rest as a known, out-of-scope residual: - * - * "Other files under `_system/` — e.g. the metadata field registry, which - * stamps its own `lastUpdated` on every persist — are a pre-existing, - * separate concern outside this fix's scope." - * - * This is that residual, closed. MEASURED on the base before the fix, a - * read-only open → read → close rewrote FOUR files: - * - * _system/__metadata_field_registry__.json.gz - * _system/type-statistics.json.gz - * _system/subtype-statistics.json.gz - * _system/verb-subtype-statistics.json.gz - * - * THE CAUSE was not the closes the marker fix guarded — it was Phase 1 of - * `closeDurableSteps`, where every component flush ran unconditionally. A flush - * is a write by definition: `MetadataIndexManager#flush()` saves the field - * registry "even with no dirty fields" (its own comment), and the storage - * adapter's count flush re-stamps the three statistics files. A session that - * committed nothing re-stamped all four. Phase 2's closes were ungated too — - * the graph index's close drains both LSM MemTables and stamps a watermark, - * and the optional vector/metadata `close` hooks (unimplemented in the - * reference engine, filled in by a native provider) persist buffered state. - * - * THE LAW. A reader writes nothing, anywhere under `_system/`, at open or at - * close. It still RELEASES what it holds: the graph index's auto-flush interval - * is cleared through `stopBackgroundFlush()`, the non-writing half of its - * close, so nothing outlives the session. - * - * WHY IT MATTERS beyond tidiness: `_system/` is where a store keeps its - * evidence about itself — what the writer committed, what the projections have - * seen. A reader that rewrites any of it is vouching for a state it only - * observed, and on shared or snapshot storage it mutates bytes another process - * owns. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' - -/** Recursively hash every regular file under `dir`, keyed by its path relative to `dir`. */ -function snapshotDir(dir: string): Map { - const out = new Map() - const walk = (rel: string): void => { - const abs = rel ? join(dir, rel) : dir - let entries: string[] - try { - entries = readdirSync(abs) - } catch { - return - } - for (const name of entries) { - const childRel = rel ? join(rel, name) : name - const childAbs = join(dir, childRel) - const st = statSync(childAbs) - if (st.isDirectory()) { - walk(childRel) - } else if (st.isFile()) { - out.set(childRel, createHash('sha256').update(readFileSync(childAbs)).digest('hex')) - } - } - } - walk('') - return out -} - -/** Every path where `after` differs from `before`, labelled — the failure message. */ -function diff(before: Map, after: Map): string[] { - const lines: string[] = [] - for (const [path, hash] of after) { - if (!before.has(path)) lines.push(`ADDED ${path}`) - else if (before.get(path) !== hash) lines.push(`CHANGED ${path}`) - } - for (const path of before.keys()) if (!after.has(path)) lines.push(`REMOVED ${path}`) - return lines.sort() -} - -describe('a read-only brain writes nothing under `_system/`', () => { - let dir: string - let brain: Brainy | null = null - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'brainy-readonly-writes-')) - }) - - afterEach(async () => { - if (brain) { - try { - await brain.close() - } catch { - /* already closed */ - } - brain = null - } - try { - rmSync(dir, { recursive: true, force: true }) - } catch { - /* ignore */ - } - }) - - const systemDir = () => join(dir, '_system') - - /** - * A writer seeds a store with nouns, verbs and queryable metadata — enough - * that the field registry, the statistics files and the graph index all hold - * real content — then closes cleanly. - */ - async function seedStore(): Promise { - const writer = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir } - }) - await writer.init() - for (let i = 0; i < 6; i++) { - await writer.add({ - id: `seed-${i}`, - data: `seed entity ${i}`, - type: i % 2 === 0 ? NounType.Concept : NounType.Document, - metadata: { lane: i % 2 === 0 ? 'alpha' : 'beta', rank: i, tags: [`t${i}`, 'shared'] }, - vector: [] - }) - } - for (let i = 1; i < 6; i++) { - await writer.relate({ from: 'seed-0', to: `seed-${i}`, type: VerbType.RelatedTo }) - } - await writer.flush() - await writer.close() - } - - it('open → read → close leaves every file under `_system/` byte-identical', async () => { - await seedStore() - - const before = snapshotDir(systemDir()) - expect(before.size, 'the writer left a populated `_system/`').toBeGreaterThan(0) - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - expect(brain.isReadOnly).toBe(true) - - // Exercise the read surface that drives each subsystem: statistics (counts), - // a metadata filter (field index + registry), a graph walk (adjacency), a - // vector search, and a direct get. - await brain.stats() - await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) - await brain.find({ where: { tags: 'shared' }, limit: 10 } as any) - await brain.find({ connected: { from: 'seed-0', direction: 'out' }, limit: 10 } as any) - await brain.get('seed-1') - - await brain.close() - brain = null - - const after = snapshotDir(systemDir()) - const changes = diff(before, after) - expect(changes, `a reader modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) - }, 120_000) - - it('names the four files that used to change — the measured shape of the defect', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - // These are the exact paths the base rewrote. Naming them keeps the pin - // honest about what it caught: if a future change reintroduces the write, - // the test above fails and this one says which subsystem did it. - const previouslyRewritten = [ - '__metadata_field_registry__.json.gz', - 'type-statistics.json.gz', - 'subtype-statistics.json.gz', - 'verb-subtype-statistics.json.gz' - ] - for (const name of previouslyRewritten) { - expect(before.has(name), `fixture must contain ${name}`).toBe(true) - } - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await brain.stats() - await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) - await brain.close() - brain = null - - const after = snapshotDir(systemDir()) - for (const name of previouslyRewritten) { - expect(after.get(name), `${name} was rewritten by a reader`).toBe(before.get(name)) - } - }, 120_000) - - it('a reader that only opens and closes — touching nothing — writes nothing', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await brain.close() - brain = null - - const changes = diff(before, snapshotDir(systemDir())) - expect(changes, `an idle reader modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) - }, 120_000) - - it('two readers in sequence each leave the store exactly as they found it', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - for (let i = 0; i < 2; i++) { - const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await reader.find({ where: { lane: 'beta' }, limit: 10 } as any) - await reader.close() - const changes = diff(before, snapshotDir(systemDir())) - expect(changes, `reader ${i + 1} modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) - } - }, 120_000) - - it('the store outside `_system/` is untouched too — a reader writes nowhere', async () => { - await seedStore() - const before = snapshotDir(dir) - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await brain.stats() - await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) - await brain.close() - brain = null - - const changes = diff(before, snapshotDir(dir)) - expect(changes, `a reader modified the store:\n${changes.join('\n')}`).toEqual([]) - }, 120_000) - - it('a WRITER still persists on close — the guard did not disarm the write path', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer.init() - await writer.add({ - id: 'after-reader', - data: 'a new row', - type: NounType.Concept, - metadata: { lane: 'gamma', rank: 99 }, - vector: [] - }) - await writer.close() - - // The writer's close DID move `_system/` — that is the whole point of the - // asymmetry, and the guard must not have flattened it. - expect(diff(before, snapshotDir(systemDir())).length).toBeGreaterThan(0) - - // And the row is really there on the next open. - const reopened = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await reopened.init() - brain = reopened - const hits = await reopened.find({ where: { lane: 'gamma' }, limit: 10 } as any) - expect(hits.length).toBe(1) - }, 120_000) -}) diff --git a/tests/integration/shutdown-single-owner.test.ts b/tests/integration/shutdown-single-owner.test.ts deleted file mode 100644 index 39f2ffc8..00000000 --- a/tests/integration/shutdown-single-owner.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * @module tests/integration/shutdown-single-owner - * @description ONE SHUTDOWN, ONE OWNER. - * - * MEASURED IN PRODUCTION. A host that owns its own shutdown — one SIGTERM - * listener calling `close()` on every pooled store — ran head-on into the - * engine's own signal handler, which iterated every live instance, flushed its - * components in parallel, and released its writer lock in a `finally`. Two - * teardowns of the same brain at the same moment. The log shape: - * - * "Shutdown signal received - flushing pending data..." (SIGTERM) - * ...148 seconds of silence... - * "Flushed successfully (1 instance)" - * ...the host's pool close of that same store returns 1s later - * - * 149s for the one store with engine work in flight, against 24s for its six - * idle siblings. The same race in a local reproduction printed - * `Failed to flush one Brainy instance on shutdown: Writer fence lost … the - * lock file is gone` — the handler observing a lock the close it was racing - * had already released. - * - * The contract pinned here: - * (a) A host owner and the engine's hooks both live: EXACTLY ONE close runs - * per brain, no fence is lost, both durability markers are written, the - * process exits 0, and the reopen adopts rather than folding. - * (b) No host owner: the engine's handler closes every instance by the same - * `close()` path — markers written, clean exit. - * (c) `close()` is idempotent and re-entrant: concurrent callers share ONE - * execution and all of them settle. - * (d) Flush is single-flight: N kicks during a running flush arm exactly one - * follow-up, and two flush bodies never overlap. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' -import { spawn } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' - -const REPO_ROOT = process.cwd() -const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx') -const BRAINY_SRC = join(REPO_ROOT, 'src', 'brainy.ts') - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)) -} - -/** The writer lock's clean-close record — written by `releaseWriterLock()`. */ -const closeRecordPath = (dir: string) => join(dir, 'locks', '_writer.close') -/** - * The generation store's clean-shutdown marker — the adopt-vs-fold gate. - * (`FileSystemStorage` gzips raw objects, so the file on disk carries `.gz`; - * both spellings are accepted so the pin survives a compression change.) - */ -const cleanShutdownWritten = (dir: string) => - existsSync(join(dir, '_system', 'clean-shutdown.json.gz')) || - existsSync(join(dir, '_system', 'clean-shutdown.json')) - -/** - * Write a child script and start it under tsx, in its OWN process group so a - * group-wide signal reaches the grandchild that actually holds the writer - * lock. (A file, not `tsx -e`: the eval form compiles to CommonJS, which has - * no top-level await.) - */ -function startChild(scriptDir: string, body: string): ReturnType { - const scriptPath = join(scriptDir, 'child-process.mts') - writeFileSync(scriptPath, body) - return spawn(TSX, [scriptPath], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true - }) -} - -/** Start a child and resolve once it prints READY, collecting all its output. */ -function startAndAwaitReady( - scriptDir: string, - body: string -): Promise<{ child: ReturnType; output: () => string }> { - const child = startChild(scriptDir, body) - let out = '' - child.stdout?.on('data', (d) => { out += String(d) }) - child.stderr?.on('data', (d) => { out += String(d) }) - return new Promise((resolvePromise, rejectPromise) => { - const timer = setTimeout( - () => rejectPromise(new Error(`child never became READY:\n${out}`)), - 120_000 - ) - child.stdout?.on('data', () => { - if (out.includes('READY')) { - clearTimeout(timer) - resolvePromise({ child, output: () => out }) - } - }) - child.on('exit', (code) => { - clearTimeout(timer) - if (!out.includes('READY')) rejectPromise(new Error(`child exited ${code} before READY:\n${out}`)) - }) - }) -} - -/** Capture console.warn/error/log lines emitted while `fn` runs. */ -async function captureConsole(fn: () => Promise): Promise<{ result: T; lines: string[] }> { - const lines: string[] = [] - const orig = { log: console.log, warn: console.warn, error: console.error } - const sink = (...args: unknown[]) => { lines.push(args.map((a) => String(a)).join(' ')) } - console.log = sink as typeof console.log - console.warn = sink as typeof console.warn - console.error = sink as typeof console.error - try { - return { result: await fn(), lines } - } finally { - console.log = orig.log - console.warn = orig.warn - console.error = orig.error - } -} - -/** - * Reopen a store and assert the open ADOPTED: no crash-recovery fold, no - * stale-lock verdict. This is the whole point of a close having run exactly - * once — a fold is measured in tens of seconds on a real store. - */ -async function expectCleanReopen(dir: string): Promise { - const { result, lines } = await captureConsole(async () => { - const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await next.init() - return next - }) - try { - expect(lines.filter((l) => /log-authority recovery|unclean shutdown detected/i.test(l))).toEqual([]) - expect(lines.filter((l) => /Overwriting stale writer lock|appears dead/i.test(l))).toEqual([]) - } finally { - await result.close() - } -} - -/** The child's counts of closes entered and close bodies run, per brain. */ -function readResult( - resultPath: string, - out: string -): { entries: Record; bodies: Record; releases: Record } { - if (!existsSync(resultPath)) throw new Error(`child wrote no result file:\n${out}`) - return JSON.parse(readFileSync(resultPath, 'utf-8')) -} - -/** - * The child-side instrumentation, shared by (a) and (b): count how many times - * `close()` is ENTERED per brain and how many times its body actually RUNS. - * The counting wrapper is an OWN property, so it shadows the prototype for - * every caller — including the engine's own signal handler, which calls - * `instance.close()`. - * - * `report()` writes SYNCHRONOUSLY to a file: it runs on the way out of the - * process (the engine's handler calls `process.exit(0)` when it is the sole - * shutdown owner), and a `console.log` to a pipe is asynchronous and can be - * dropped by that exit. - */ -function childCounters(resultPath: string): string { - return ` - const entries = {} - const bodies = {} - const releases = {} - function instrument(name, brain) { - entries[name] = 0 - bodies[name] = 0 - releases[name] = 0 - const enter = brain.close.bind(brain) - brain.close = () => { entries[name]++; return enter() } - const durable = brain.closeDurableSteps.bind(brain) - brain.closeDurableSteps = () => { bodies[name]++; return durable() } - // The writer lock is the ownership witness: the old handler released it - // in its own finally, on top of the owner's close doing the same. - const storage = brain.storage - const release = storage.releaseWriterLock.bind(storage) - storage.releaseWriterLock = () => { releases[name]++; return release() } - } - const report = () => { - __writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ entries, bodies, releases })) - } -` -} - -describe('shutdown has exactly one owner', () => { - let dirA: string - let dirB: string - let scriptDir: string - let resultPath: string - - beforeEach(() => { - dirA = makeTempDir('brainy-shutdown-owner-a-') - dirB = makeTempDir('brainy-shutdown-owner-b-') - scriptDir = makeTempDir('brainy-shutdown-owner-script-') - resultPath = join(scriptDir, 'result.json') - }) - - afterEach(() => { - for (const d of [dirA, dirB, scriptDir]) { - try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ } - } - }) - - it('(a) a host owner closes both brains and the engine handler steps aside', async () => { - const script = ` - import { writeFileSync as __writeFileSync } from 'node:fs' - import { Brainy } from ${JSON.stringify(BRAINY_SRC)} - const a = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirA)} } }) - const b = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirB)} } }) - await a.init() - await b.init() - await a.add({ data: 'row in brain a', type: 'concept' }) - await b.add({ data: 'row in brain b', type: 'concept' }) - ${childCounters(resultPath)} - instrument('a', a) - instrument('b', b) - // THE HOST'S OWN SHUTDOWN OWNER, registered after the engine's hooks — - // the ordinary shape: the pool was built before the signal wiring. - process.on('SIGTERM', async () => { - await Promise.all([a.close(), b.close()]) - // Stay alive a beat so the engine's deferred handler gets its turn and - // has to decide what to do about two already-closed brains. - await new Promise((r) => setTimeout(r, 1500)) - report() - process.exit(0) - }) - console.log('READY') - setInterval(() => {}, 1000) - ` - const { child, output } = await startAndAwaitReady(scriptDir, script) - - process.kill(-(child.pid as number), 'SIGTERM') - const code = await new Promise((r) => child.on('exit', (c) => r(c))) - // The tsx wrapper's exit event and the grandchild that actually held the - // locks are asynchronous with each other — let its last writes land. - await new Promise((r) => setTimeout(r, 750)) - const out = output() - - // The process shut down cleanly. - expect(code, `child output:\n${out}`).toBe(0) - - // EXACTLY ONE close per brain — entered once, body run once. A second - // entry would mean the engine's handler closed a brain its owner was - // already closing; a second body would mean close() is not single-flight. - const { entries, bodies, releases } = readResult(resultPath, out) - expect(entries).toEqual({ a: 1, b: 1 }) - expect(bodies).toEqual({ a: 1, b: 1 }) - // ...and the writer lock was given up exactly once per brain. This is the - // assertion that fails on the old handler, which released the lock in its - // own `finally` on top of the owner's close doing the same — two owners. - expect(releases).toEqual({ a: 1, b: 1 }) - - // The engine's handler ran (it announced the signal) and stepped aside for - // both brains rather than touching them. setImmediate lands in the check - // phase of the same loop turn, so a close that has begun cannot have - // finished — it is still in flight when the handler looks. - expect(out).toContain('Shutdown signal received') - expect(out).toMatch(/2 Brainy instances are already closing/) - - // Nothing was taken out from under the owner, and nothing failed. - expect(out).not.toMatch(/Writer fence lost/i) - expect(out).not.toMatch(/Failed to (flush|close) one Brainy instance/i) - - // Both durability markers, both brains: the writer lock's clean-close - // record and the generation store's clean-shutdown marker. - for (const dir of [dirA, dirB]) { - expect(existsSync(closeRecordPath(dir)), `clean-close record missing in ${dir}`).toBe(true) - expect(cleanShutdownWritten(dir), `clean-shutdown marker missing in ${dir}`).toBe(true) - } - - // And the next open adopts instead of folding. - await expectCleanReopen(dirA) - await expectCleanReopen(dirB) - }, 240_000) - - it('(b) with no host owner the engine closes every instance the same way', async () => { - const script = ` - import { writeFileSync as __writeFileSync } from 'node:fs' - import { Brainy } from ${JSON.stringify(BRAINY_SRC)} - const a = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirA)} } }) - const b = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirB)} } }) - await a.init() - await b.init() - await a.add({ data: 'row in brain a', type: 'concept' }) - await b.add({ data: 'row in brain b', type: 'concept' }) - ${childCounters(resultPath)} - instrument('a', a) - instrument('b', b) - process.on('exit', report) - console.log('READY') - setInterval(() => {}, 1000) - ` - const { child, output } = await startAndAwaitReady(scriptDir, script) - - process.kill(-(child.pid as number), 'SIGTERM') - const code = await new Promise((r) => child.on('exit', (c) => r(c))) - // The tsx wrapper's exit event and the grandchild that actually held the - // locks are asynchronous with each other — let its last writes land. - await new Promise((r) => setTimeout(r, 750)) - const out = output() - - expect(code, `child output:\n${out}`).toBe(0) - - // The engine owned this shutdown: one close per brain, through close(). - const { entries, bodies, releases } = readResult(resultPath, out) - expect(entries).toEqual({ a: 1, b: 1 }) - expect(bodies).toEqual({ a: 1, b: 1 }) - expect(releases).toEqual({ a: 1, b: 1 }) - expect(out).toContain('Shutdown signal received') - expect(out).toMatch(/Flushed successfully \(2 instances\)/) - expect(out).not.toMatch(/Writer fence lost/i) - expect(out).not.toMatch(/Failed to (flush|close) one Brainy instance/i) - - for (const dir of [dirA, dirB]) { - expect(existsSync(closeRecordPath(dir)), `clean-close record missing in ${dir}`).toBe(true) - expect(cleanShutdownWritten(dir), `clean-shutdown marker missing in ${dir}`).toBe(true) - } - - await expectCleanReopen(dirA) - await expectCleanReopen(dirB) - }, 240_000) - - it('(c) two concurrent close() callers share ONE execution, and both settle', async () => { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dirA } }) - await brain.init() - await brain.add({ data: 'one row', type: NounType.Concept }) - - const inner = brain as unknown as { closeDurableSteps: () => Promise } - const durable = inner.closeDurableSteps.bind(inner) - let bodies = 0 - inner.closeDurableSteps = () => { bodies++; return durable() } - - expect(brain.isClosing).toBe(false) - expect(brain.isClosed).toBe(false) - - const first = brain.close() - // The state is observable IMMEDIATELY — a signal handler that yields a - // tick and comes back must not read a stale "not yet". - expect(brain.isClosing).toBe(true) - const second = brain.close() - expect(first === second, 'concurrent callers must share the one promise').toBe(true) - - await Promise.all([first, second]) - expect(bodies).toBe(1) - expect(brain.isClosed).toBe(true) - - // A caller arriving after the close finished gets the same settled answer, - // and nothing runs again. - await brain.close() - expect(bodies).toBe(1) - - expect(existsSync(closeRecordPath(dirA))).toBe(true) - expect(cleanShutdownWritten(dirA)).toBe(true) - }, 120_000) - - it('(d) N kicks during a running flush arm exactly one follow-up, never a second flush', async () => { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dirA } }) - await brain.init() - - const inner = brain as unknown as { - _flushBodyRuns: number - _flushConcurrencyPeak: number - _flushInFlight: Promise | null - _flushQueued: Promise | null - _persistBackgroundFlight: Promise | null - metadataIndex: { flush: () => Promise } - kickBackgroundFlush: (reason: 'threshold' | 'idle') => void - } - - // Widen the flush body's window so the kicks land INSIDE it — the - // production shape, where two flushes overlapped 3s apart. - const metaFlush = inner.metadataIndex.flush.bind(inner.metadataIndex) - inner.metadataIndex.flush = async () => { - await new Promise((r) => setTimeout(r, 400)) - return metaFlush() - } - - await brain.add({ data: 'a write to flush', type: NounType.Concept }) - const runsBefore = inner._flushBodyRuns - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 50)) // the leader is inside its body - expect(inner._flushInFlight, 'a flush is running').not.toBeNull() - - // The cadence kicks — the door named in the defect — plus direct callers - // (an application flush, the cross-process flush-request watcher). - for (let i = 0; i < 5; i++) inner.kickBackgroundFlush('threshold') - const direct = [brain.flush(), brain.flush(), brain.flush()] - - // EXACTLY ONE follow-up is armed, however many callers arrived. - expect(inner._flushQueued, 'the eight kicks armed one follow-up').not.toBeNull() - - await Promise.all([leader, ...direct, inner._persistBackgroundFlight ?? Promise.resolve()]) - - // One leader + one follow-up. Not nine, and never two at once. - expect(inner._flushBodyRuns - runsBefore).toBe(2) - expect(inner._flushConcurrencyPeak).toBe(1) - expect(inner._flushInFlight).toBeNull() - expect(inner._flushQueued).toBeNull() - - inner.metadataIndex.flush = metaFlush - await brain.close() - }, 120_000) -}) diff --git a/tests/integration/storage-batch-operations.test.ts b/tests/integration/storage-batch-operations.test.ts index 53547fe5..9972df1a 100644 --- a/tests/integration/storage-batch-operations.test.ts +++ b/tests/integration/storage-batch-operations.test.ts @@ -95,13 +95,7 @@ describe('Storage-Level Batch Operations v5.12.0', () => { expect(entity?.vector?.length).toBeGreaterThan(0) }) - it('should be faster than individual gets for large batches', async (ctx) => { - // Wall-clock RATIO assertion — belongs to the perf lane (npm run - // test:perf), not the correctness gate: under the exclusive release - // gate this flaked when individual gets got faster on their own - // (open-path/hydration changes), not because batchGet regressed. - ctx.skip(!process.env.BRAINY_PERF_LANE, 'timing-ratio assertion — runs only under the perf lane (npm run test:perf)') - + it('should be faster than individual gets for large batches', async () => { // Create 100 entities const ids: string[] = [] for (let i = 0; i < 100; i++) { diff --git a/tests/integration/triple-intelligence-correctness.test.ts b/tests/integration/triple-intelligence-correctness.test.ts deleted file mode 100644 index 53848d1a..00000000 --- a/tests/integration/triple-intelligence-correctness.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Triple Intelligence Correctness Tests - * - * Moved out of tests/performance/triple-intelligence-scale.test.ts (the - * perf-lane split excludes the whole `tests/performance/**` directory from - * the correctness gate — see vitest.config.ts's exclude list — which left - * this describe's 4 tests running nowhere by default). Every `expect(...)` - * below is byte-for-byte what the original file asserted — nothing here - * changes an assertion. - * - * Fixture-only fixes were required to make this run at all against the - * current engine — exactly the kind of drift that running nowhere hides - * (tsconfig.json excludes `**\/*.test.ts`, so tsc never typechecked this file - * either, and nothing else exercised it since the perf-lane split): - * `addMany()` now takes `{ items }`, not a bare array; `relate()`'s `type` is - * a `VerbType` enum value, not the string `'related'`; `add()`'s `type` is - * required at runtime (`type: NounType.Document` added — no test asserts on - * it); the `where` filter spells its operators bare (`gte`, not `$gte`); - * `storage: { type: 'memory' }` avoids tests/setup.ts's global per-test - * `rm -rf brainy-data` tearing the writer lock out from under this describe's - * shared (beforeAll) brain between tests. - * - * Two of the four tests are `it.skip` with a defect filed in a comment above - * each, not patched: `graphTraversal()` bypasses the 8.0 id-normalization law - * (a natural-key `connected.from` never resolves), and `vectorSearch()` - * throws a hardcoded O(log n) wall-time guard that a 6-row fixture's cold - * WASM/JIT cost blows through by 6-15x — both genuine TripleIntelligenceSystem - * defects the original file never surfaced because it ran (when it ran at - * all, in-process) after a 1M-item warm-up suite. See each skip's comment. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { TripleIntelligenceSystem } from '../../src/triple/TripleIntelligenceSystem.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' - -describe('Triple Intelligence Correctness', () => { - let brain: Brainy - let triple: TripleIntelligenceSystem - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false }) - await brain.init({ - enableMetadataIndex: true, - enableGraphIndex: true, - // Memory, not the 'auto' default's FileSystemStorage at ./brainy-data: - // tests/setup.ts's global per-test `rm -rf brainy-data` was ripping the - // writer lock out from under this describe's shared (beforeAll) brain - // between tests ("Writer fence lost" on close) — a store this test - // never needed to touch disk for. - storage: { type: 'memory' } - }) - - // Add test data with known patterns - const testData = [ - { id: 'doc1', data: 'Machine learning algorithms', type: NounType.Document, metadata: { topic: 'AI', year: 2023 } }, - { id: 'doc2', data: 'Deep learning neural networks', type: NounType.Document, metadata: { topic: 'AI', year: 2024 } }, - { id: 'doc3', data: 'Natural language processing', type: NounType.Document, metadata: { topic: 'AI', year: 2023 } }, - { id: 'doc4', data: 'Computer vision applications', type: NounType.Document, metadata: { topic: 'AI', year: 2024 } }, - { id: 'doc5', data: 'Quantum computing basics', type: NounType.Document, metadata: { topic: 'Physics', year: 2023 } }, - { id: 'doc6', data: 'Blockchain technology', type: NounType.Document, metadata: { topic: 'Crypto', year: 2024 } } - ] - - await brain.addMany({ items: testData }) - - // Add relationships - await brain.relate({ from: 'doc1', to: 'doc2', type: VerbType.RelatedTo }) - await brain.relate({ from: 'doc2', to: 'doc3', type: VerbType.RelatedTo }) - await brain.relate({ from: 'doc3', to: 'doc4', type: VerbType.RelatedTo }) - - triple = brain.getTripleIntelligence() - }) - - afterAll(async () => { - await brain?.close() - }) - - it('should return exact matches for field queries', async () => { - const results = await triple.find({ - where: { topic: 'AI' }, - limit: 10 - }) - - expect(results).toHaveLength(4) - for (const result of results) { - expect(result.metadata.topic).toBe('AI') - } - }) - - it('should handle range queries correctly', async () => { - const results = await triple.find({ - where: { year: { gte: 2024 } }, - limit: 10 - }) - - expect(results).toHaveLength(3) - for (const result of results) { - expect(result.metadata.year).toBeGreaterThanOrEqual(2024) - } - }) - - // SKIPPED — genuine TripleIntelligenceSystem defect, out of test-hygiene - // scope, filed rather than patched: graphTraversal() (TripleIntelligenceSystem.ts) - // calls storage.getNoun(id) / graphIndex.getNeighbors(id) directly with the - // caller's raw `connected.from` string, bypassing the 8.0 id-normalization - // law (Brainy.add() coerces a natural-key id like 'doc1' to a stable v5 - // UUID and stores the original only for translation at the public API - // surface — see coerceNewEntityId in brainy.ts). A caller passing a - // natural-key id here gets storage.getNoun('doc1') → undefined; every - // result's `id` is whatever raw string seeded the BFS queue, so results - // can never match by natural key either. Reproduces identically against - // the pre-move fixture and code — not introduced by this file's move, just - // never exercised (this describe ran nowhere since the perf-lane split). - it.skip('should traverse graph relationships', async () => { - const results = await triple.find({ - connected: { from: 'doc1', depth: 2 }, - limit: 10 - }) - - // Should find doc1, doc2 (depth 1), and doc3 (depth 2) - const ids = results.map(r => r.id) - expect(ids).toContain('doc1') - expect(ids).toContain('doc2') - expect(ids).toContain('doc3') - - // Check depth values - const doc1Result = results.find(r => r.id === 'doc1') - const doc2Result = results.find(r => r.id === 'doc2') - const doc3Result = results.find(r => r.id === 'doc3') - - expect(doc1Result?.depth).toBe(0) - expect(doc2Result?.depth).toBe(1) - expect(doc3Result?.depth).toBe(2) - }) - - // SKIPPED — genuine TripleIntelligenceSystem defect, out of test-hygiene - // scope, filed rather than patched: vectorSearch() (TripleIntelligenceSystem.ts) - // throws `Vector search O(log n) violation` when elapsed wall time exceeds - // `log2(hnswIndex.size()) * 5 * 2` — on a 6-row fixture that bound is - // ~25.8ms, which the real cost of a WASM/Candle embed call plus first-call - // JIT/cache warmup blows through by 6-15x (measured 166-375ms across - // repeated runs) — a hardcoded constant that assumes an already-warm, - // presumably-native runtime, not this environment. The ORIGINAL file never - // hit this: it ran after 'Triple Intelligence Performance at Scale', whose - // 1M-item setup + many queries left the embedder/HNSW thoroughly warm by - // the time this describe's tests ran in the same process — an accidental - // dependency on a sibling suite, not a property of this test. Standalone, - // cold, it is inherently flaky by the SUT's own design, not fixable by - // fixture changes (enlarging the fixture only pushes elapsed time up - // alongside the threshold's log-scaled — not linear — growth). - it.skip('should combine signals with proper fusion', async () => { - const results = await triple.find({ - similar: 'deep learning', - where: { topic: 'AI' }, - limit: 3 - }, { - fusion: { - strategy: 'rrf', - weights: { vector: 0.7, field: 0.3 } - } - }) - - // doc2 should rank highest (matches both signals) - expect(results[0].id).toBe('doc2') - expect(results[0].fusionScore).toBeGreaterThan(0) - - // All results should have AI topic - for (const result of results) { - expect(result.metadata.topic).toBe('AI') - } - }) -}) diff --git a/tests/performance/triple-intelligence-scale.test.ts b/tests/performance/triple-intelligence-scale.test.ts index 1db7fc80..6687decd 100644 --- a/tests/performance/triple-intelligence-scale.test.ts +++ b/tests/performance/triple-intelligence-scale.test.ts @@ -352,8 +352,106 @@ describe('Triple Intelligence Performance at Scale', () => { }) }) -// The former 'Triple Intelligence Correctness' describe (4 tests, no timing -// assertions) moved to tests/integration/triple-intelligence-correctness.test.ts -// so it runs in the default correctness gate — this whole directory -// (tests/performance/**) is excluded from that gate (see vitest.config.ts), -// which had silently stopped running those 4 tests after the perf-lane split. \ No newline at end of file +describe('Triple Intelligence Correctness', () => { + let brain: Brainy + let triple: TripleIntelligenceSystem + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false }) + await brain.init({ + enableMetadataIndex: true, + enableGraphIndex: true + }) + + // Add test data with known patterns + const testData = [ + { id: 'doc1', data: 'Machine learning algorithms', metadata: { topic: 'AI', year: 2023 } }, + { id: 'doc2', data: 'Deep learning neural networks', metadata: { topic: 'AI', year: 2024 } }, + { id: 'doc3', data: 'Natural language processing', metadata: { topic: 'AI', year: 2023 } }, + { id: 'doc4', data: 'Computer vision applications', metadata: { topic: 'AI', year: 2024 } }, + { id: 'doc5', data: 'Quantum computing basics', metadata: { topic: 'Physics', year: 2023 } }, + { id: 'doc6', data: 'Blockchain technology', metadata: { topic: 'Crypto', year: 2024 } } + ] + + await brain.addMany(testData) + + // Add relationships + await brain.relate({ from: 'doc1', to: 'doc2', type: 'related' }) + await brain.relate({ from: 'doc2', to: 'doc3', type: 'related' }) + await brain.relate({ from: 'doc3', to: 'doc4', type: 'related' }) + + triple = brain.getTripleIntelligence() + }) + + afterAll(async () => { + await brain?.close() + }) + + it('should return exact matches for field queries', async () => { + const results = await triple.find({ + where: { topic: 'AI' }, + limit: 10 + }) + + expect(results).toHaveLength(4) + for (const result of results) { + expect(result.metadata.topic).toBe('AI') + } + }) + + it('should handle range queries correctly', async () => { + const results = await triple.find({ + where: { year: { $gte: 2024 } }, + limit: 10 + }) + + expect(results).toHaveLength(3) + for (const result of results) { + expect(result.metadata.year).toBeGreaterThanOrEqual(2024) + } + }) + + it('should traverse graph relationships', async () => { + const results = await triple.find({ + connected: { from: 'doc1', depth: 2 }, + limit: 10 + }) + + // Should find doc1, doc2 (depth 1), and doc3 (depth 2) + const ids = results.map(r => r.id) + expect(ids).toContain('doc1') + expect(ids).toContain('doc2') + expect(ids).toContain('doc3') + + // Check depth values + const doc1Result = results.find(r => r.id === 'doc1') + const doc2Result = results.find(r => r.id === 'doc2') + const doc3Result = results.find(r => r.id === 'doc3') + + expect(doc1Result?.depth).toBe(0) + expect(doc2Result?.depth).toBe(1) + expect(doc3Result?.depth).toBe(2) + }) + + it('should combine signals with proper fusion', async () => { + const results = await triple.find({ + similar: 'deep learning', + where: { topic: 'AI' }, + limit: 3 + }, { + fusion: { + strategy: 'rrf', + weights: { vector: 0.7, field: 0.3 } + } + }) + + // doc2 should rank highest (matches both signals) + expect(results[0].id).toBe('doc2') + expect(results[0].fusionScore).toBeGreaterThan(0) + + // All results should have AI topic + for (const result of results) { + expect(result.metadata.topic).toBe('AI') + } + }) +}) \ No newline at end of file diff --git a/tests/regression/metadata-field-typing.unit.test.ts b/tests/regression/metadata-field-typing.unit.test.ts deleted file mode 100644 index 910d4f2a..00000000 --- a/tests/regression/metadata-field-typing.unit.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @module metadata-field-typing.unit.test - * @description Regression: a metadata field that holds more than one value - * KIND stays fully filterable on every kind it holds. - * - * The defect this pins, reproduced on the released engine: the metadata index - * fixed a field's value type from the FIRST value it saw, and every later value - * of a different type was coerced to that type or, when coercion failed, - * dropped from the index in silence. Writing `category: 'electronics'` rows and - * then `category: 5` rows left `find({ where: { category: 5 } })` returning - * nothing — while the same rows in a numbers-only corpus answered correctly. - * The rows themselves were never lost: they stayed readable by id and by vector - * search, and only ever went missing from equality filters on that one field, - * which is what made it so quiet. - * - * Order is the whole point of these cases. Neither writer owns the field, so - * strings-then-numbers and numbers-then-strings must give the same answers. - */ - -import { describe, it, expect } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' - -/** A brain over memory storage, with a corpus written in the given order. */ -async function brainWith( - rows: Array<{ label: string; category: unknown }> -): Promise { - const brainy = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brainy.init() - for (const row of rows) { - await brainy.add({ - data: `item ${row.label}`, - type: NounType.Thing, - metadata: { label: row.label, category: row.category } - }) - } - return brainy -} - -const labelsOf = (results: Array<{ metadata?: Record }>): string[] => - results.map((r) => String(r.metadata?.label)).sort() - -describe('regression: a mixed-kind metadata field filters on every kind', { timeout: 180_000 }, () => { - it('finds number rows written after string rows', async () => { - const brainy = await brainWith([ - { label: 'e1', category: 'electronics' }, - { label: 'f1', category: 'furniture' }, - { label: 'n1', category: 5 }, - { label: 'n2', category: 5 }, - { label: 'n3', category: 7 } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: 5 }, limit: 100 }))).toEqual(['n1', 'n2']) - expect(labelsOf(await brainy.find({ where: { category: 7 }, limit: 100 }))).toEqual(['n3']) - expect(labelsOf(await brainy.find({ where: { category: 'electronics' }, limit: 100 }))).toEqual(['e1']) - expect(labelsOf(await brainy.find({ where: { category: 'furniture' }, limit: 100 }))).toEqual(['f1']) - } finally { - await brainy.close() - } - }) - - it('finds string rows written after number rows', async () => { - const brainy = await brainWith([ - { label: 'n1', category: 5 }, - { label: 'n2', category: 5 }, - { label: 'e1', category: 'electronics' }, - { label: 'e2', category: 'electronics' } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: 'electronics' }, limit: 100 }))).toEqual(['e1', 'e2']) - expect(labelsOf(await brainy.find({ where: { category: 5 }, limit: 100 }))).toEqual(['n1', 'n2']) - } finally { - await brainy.close() - } - }) - - it('keeps `5` and `\'5\'` apart — a kind is part of the value, not a formatting detail', async () => { - const brainy = await brainWith([ - { label: 'num', category: 5 }, - { label: 'str', category: '5' } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: 5 }, limit: 100 }))).toEqual(['num']) - expect(labelsOf(await brainy.find({ where: { category: '5' }, limit: 100 }))).toEqual(['str']) - } finally { - await brainy.close() - } - }) - - it('serves booleans mixed into a field that already holds strings', async () => { - const brainy = await brainWith([ - { label: 's1', category: 'yes' }, - { label: 'b1', category: true }, - { label: 'b2', category: false } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: true }, limit: 100 }))).toEqual(['b1']) - expect(labelsOf(await brainy.find({ where: { category: false }, limit: 100 }))).toEqual(['b2']) - expect(labelsOf(await brainy.find({ where: { category: 'yes' }, limit: 100 }))).toEqual(['s1']) - } finally { - await brainy.close() - } - }) - - it('ranges over the numeric part of a mixed field', async () => { - const brainy = await brainWith([ - { label: 'unpriced', category: 'on request' }, - { label: 'cheap', category: 100 }, - { label: 'mid', category: 500 }, - { label: 'dear', category: 900 } - ]) - try { - const found = await brainy.find({ - where: { category: { greaterThan: 200 } }, - limit: 100 - }) - expect(labelsOf(found)).toEqual(['dear', 'mid']) - } finally { - await brainy.close() - } - }) -}) diff --git a/tests/unit/brainy/batch-operations.test.ts b/tests/unit/brainy/batch-operations.test.ts index 16f0f93d..889127ee 100644 --- a/tests/unit/brainy/batch-operations.test.ts +++ b/tests/unit/brainy/batch-operations.test.ts @@ -113,12 +113,7 @@ describe('Brainy Batch Operations', () => { items: Array.from({ length: 100 }, (_, i) => ({ data: `Bulk ${i}`, type: NounType.Thing, - metadata: { counter: 0 }, - // This test exercises updateMany's batching, not embedding — the - // sanctioned "unvectored" `[]` shape (see - // tests/integration/index-skips-unvectored.test.ts) skips the - // real embedder entirely. - vector: [] + metadata: { counter: 0 } })) }) const manyIds = manyResult.successful @@ -279,12 +274,7 @@ describe('Brainy Batch Operations', () => { const manyResult = await brain.addMany({ items: Array.from({ length: 100 }, (_, i) => ({ data: `Bulk Delete ${i}`, - type: NounType.Thing, - // This test exercises removeMany's batching, not embedding — the - // sanctioned "unvectored" `[]` shape (see - // tests/integration/index-skips-unvectored.test.ts) skips the - // real embedder entirely. - vector: [] + type: NounType.Thing })) }) const manyIds = manyResult.successful @@ -555,18 +545,10 @@ describe('Brainy Batch Operations', () => { it('should validate batch size limits', async () => { // Try to add a large batch (reduced from 10000 to 1000 for reasonable test time) - // This test validates the batch SIZE law, not embeddings — items carry - // the sanctioned "unvectored" `[]` shape (see - // tests/integration/index-skips-unvectored.test.ts) so addMany's batch - // embedder is never invoked; 1000 real embeddings under the root - // vitest config (which does not mock the embedder) is a 60-180s - // budget flake waiting to happen, not a defect in what this test - // actually asserts. const largeCount = 1000 const largeItems = Array.from({ length: largeCount }, (_, i) => ({ data: `Large ${i}`, - type: NounType.Thing, - vector: [] + type: NounType.Thing })) try { @@ -578,7 +560,12 @@ describe('Brainy Batch Operations', () => { // Might throw if there's a limit expect(error).toBeDefined() } - }) + // order-of-magnitude guard: this test batches 20x the item count of the + // sibling "perform better" test above (worst measured 11.9s for 50 + // items on CPU-only honest iron); the prior 60s timeout was itself + // observed being hit, so this is 3x that floor rather than a scaled + // extrapolation, to leave real headroom for run-to-run variance + }, 180000) it('should provide meaningful error messages', async () => { try { diff --git a/tests/unit/brainy/flush-single-flight.test.ts b/tests/unit/brainy/flush-single-flight.test.ts deleted file mode 100644 index 49d93ea8..00000000 --- a/tests/unit/brainy/flush-single-flight.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @module tests/unit/brainy/flush-single-flight - * @description THE FLUSH GATE NEVER STRANDS A WAITER. - * - * The gate serialises flushes: one body runs, at most one waits. The failure - * mode that shape invites is a promise CYCLE — a queued follow-up expressed as - * `leader.then(() => this.flush())` is settled only by resolving the promise - * the leader is being awaited through, so anything that awaits `flush()` from - * inside a flush body closes the graph on itself and nobody ever resolves. - * That is an unbounded hang, not a slow flush, and it presents exactly like a - * test timing out inside a bulk write. - * - * The gate therefore settles its waiter from the MACHINE (a bare deferred - * promoted in the leader's `finally`), never from a chain. The laws pinned - * here, each on a path that must settle the waiter: - * - * (a) many callers during one running flush → one body, one follow-up, and - * EVERY caller resolves within a bound; - * (b) the leader REJECTS → its own caller rejects, and the queued caller is - * still run and still settled; - * (c) the promoted follow-up itself rejects → its waiter rejects (settled, - * not stranded) and the gate is left open for the next flush; - * (d) the leader's promise does not wait for its follower. - */ - -import { describe, it, expect, afterEach } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { NounType } from '../../../src/types/graphTypes' - -type GateInternals = { - _flushInFlight: Promise | null - _flushQueued: Promise | null - _flushBodyRuns: number - _flushConcurrencyPeak: number - _flushSteps: () => Promise - kickBackgroundFlush: (reason: 'threshold' | 'idle') => void -} - -/** Fail loudly rather than hanging the suite: a stranded waiter never settles. */ -function withinBound(p: Promise, ms: number, what: string): Promise { - let timer: ReturnType - return Promise.race([ - p, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${what} did not settle within ${ms}ms`)), ms) - }) - ]).finally(() => clearTimeout(timer)) as Promise -} - -describe('the flush gate settles every waiter', () => { - const brains: Brainy[] = [] - - afterEach(async () => { - for (const b of brains.splice(0)) { - try { await b.close() } catch { /* already closed */ } - } - }) - - async function openBrain(): Promise> { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - brains.push(brain) - await brain.init() - await brain.add({ data: 'a write, so a flush has work', type: NounType.Thing }) - return brain - } - - it('(a) every caller arriving during one flush resolves, and only one follows', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - inner._flushSteps = async () => { - await new Promise((r) => setTimeout(r, 120)) - return realSteps() - } - - const runsBefore = inner._flushBodyRuns - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - - const joiners = [brain.flush(), brain.flush(), brain.flush(), brain.flush()] - for (let i = 0; i < 4; i++) inner.kickBackgroundFlush('threshold') - expect(inner._flushQueued, 'exactly one waiter is queued').not.toBeNull() - - await withinBound(Promise.all([leader, ...joiners]), 15_000, 'the flush callers') - - expect(inner._flushBodyRuns - runsBefore).toBe(2) - expect(inner._flushConcurrencyPeak).toBe(1) - expect(inner._flushQueued).toBeNull() - }) - - it('(b) a leader that REJECTS still runs and settles the queued waiter', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - let call = 0 - inner._flushSteps = async () => { - call++ - await new Promise((r) => setTimeout(r, 80)) - if (call === 1) throw new Error('injected: the leader flush failed') - return realSteps() - } - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - const queued = brain.flush() - - await expect(leader).rejects.toThrow(/injected: the leader flush failed/) - // The waiter is NOT collateral damage of the leader's failure: it gets its - // own run, and it settles. - await withinBound(queued, 15_000, 'the queued waiter after a failed leader') - expect(call).toBe(2) - expect(inner._flushQueued).toBeNull() - expect(inner._flushInFlight).toBeNull() - }) - - it('(c) a promoted follow-up that rejects settles its waiter and opens the gate', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - let call = 0 - inner._flushSteps = async () => { - call++ - await new Promise((r) => setTimeout(r, 80)) - if (call === 2) throw new Error('injected: the follow-up flush failed') - return realSteps() - } - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - const queued = brain.flush() - - await withinBound(leader, 15_000, 'the leader') - await withinBound( - expect(queued).rejects.toThrow(/injected: the follow-up flush failed/), - 15_000, - 'the rejected follow-up' - ) - // The gate is open: a later flush still runs. - inner._flushSteps = realSteps - await brain.add({ data: 'another write', type: NounType.Thing }) - await withinBound(brain.flush(), 15_000, 'the flush after a failed follow-up') - expect(inner._flushInFlight).toBeNull() - expect(inner._flushQueued).toBeNull() - }) - - it('(d) the leader does not wait for its follower', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - let call = 0 - inner._flushSteps = async () => { - call++ - // The follow-up is deliberately far slower than the leader. - await new Promise((r) => setTimeout(r, call === 1 ? 60 : 600)) - return realSteps() - } - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - const queued = brain.flush() - - const t0 = Date.now() - await withinBound(leader, 15_000, 'the leader') - const leaderWall = Date.now() - t0 - // If the leader awaited its follower it could not return before the - // follower's own 600ms body had run. - expect(leaderWall).toBeLessThan(500) - - await withinBound(queued, 15_000, 'the follower') - }) -}) diff --git a/tests/unit/brainy/get.test.ts b/tests/unit/brainy/get.test.ts index 97a19125..b39bf2e1 100644 --- a/tests/unit/brainy/get.test.ts +++ b/tests/unit/brainy/get.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { +import { createAddParams, generateTestVector, createTestConfig, @@ -269,75 +268,32 @@ describe('Brainy.get()', () => { expect(entity!.id).toBe(id) }) - // THE INDEXABLE-ARRAY BOUND, from get()'s side. This case used to park a - // 1000-element array in the metadata bag and assert it came back. That - // shape is refused at the write door now — an array field mints one - // posting per element, so an unbounded array is an unbounded write — so - // the case pins BOTH halves of the law that replaced it: a large SCALAR - // payload still round-trips whole, and an array over the bound refuses by - // name. Every length derives from MAX_INDEXED_ARRAY_LENGTH so the pin - // follows the constant wherever it moves. - it('should get an entity with a large scalar metadata payload', async () => { - // Arrange — large in every dimension EXCEPT array length: a long string, - // many fields, deep nesting, and an array sitting exactly ON the bound. + it('should get entity with very large metadata', async () => { + // Arrange const largeMetadata = { - atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), + bigArray: new Array(1000).fill('item'), bigObject: Object.fromEntries( Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(10_000), deepNesting: Array(10).fill(null).reduce( (acc) => ({ nested: acc }), { value: 'deep' } ) } - + const id = await brain.add(createAddParams({ data: 'Large metadata', type: 'thing', metadata: largeMetadata })) - + // Act const entity = await brain.get(id) - - // Assert — the payload comes back whole, first element to last + + // Assert expect(entity).not.toBeNull() - expect(entity!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - expect(entity!.metadata.atTheBound[0]).toBe('item0') - expect(entity!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) - .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) + expect(entity!.metadata.bigArray).toHaveLength(1000) expect(Object.keys(entity!.metadata.bigObject)).toHaveLength(100) - expect(entity!.metadata.longString).toHaveLength(10_000) - - // ...including the deep nest, walked to the bottom. - let cursor: any = entity!.metadata.deepNesting - for (let depth = 0; depth < 10; depth++) cursor = cursor.nested - expect(cursor.value).toBe('deep') - }) - - it('should refuse a metadata array over the indexing bound, by name', async () => { - // Arrange - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - - // Act - const err = await brain - .add(createAddParams({ - data: 'Large metadata', - type: 'thing', - metadata: { bigArray: new Array(overTheBound).fill('item') } - })) - .catch((e: any) => e) - - // Assert — the field, the length and the bound, on the error and in the - // message, so a handler can report or repair without parsing prose. - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('bigArray') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.message).toContain('bigArray') - expect(err.message).toContain(String(overTheBound)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) }) }) diff --git a/tests/unit/brainy/relate.test.ts b/tests/unit/brainy/relate.test.ts index bea35ba3..eb1a036e 100644 --- a/tests/unit/brainy/relate.test.ts +++ b/tests/unit/brainy/relate.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -249,23 +248,16 @@ describe('Brainy.relate()', () => { expect(matches.length).toBe(1) // Only one relationship should exist }) - // THE INDEXABLE-ARRAY BOUND, from relate()'s side. This case used to pass a - // 100-element array through relate() and assert it came back — a length - // hardcoded either side of a bound it never named, so it read green or red - // purely by where the constant happened to sit. Both halves of the law are - // pinned here instead, and every length derives from - // MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant. - it('should handle a large scalar metadata payload on a relation', async () => { - // Arrange — large in every dimension EXCEPT array length: a long string, - // many fields, and an array sitting exactly ON the bound. + it('should handle very long metadata', async () => { + // Arrange const largeMetadata = { - atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), + bigArray: new Array(100).fill('item'), bigObject: Object.fromEntries( Array.from({ length: 50 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(10_000) + longString: 'x'.repeat(1000) } - + // Act await brain.relate({ from: entity1Id, @@ -273,46 +265,12 @@ describe('Brainy.relate()', () => { type: 'relatedTo', metadata: largeMetadata }) - - // Assert — the payload comes back whole, first element to last + + // Assert const relations = await brain.related({ from: entity1Id }) const relation = relations.find(r => r.to === entity2Id) expect(relation).toBeDefined() - expect(relation!.metadata?.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - expect(relation!.metadata?.atTheBound[0]).toBe('item0') - expect(relation!.metadata?.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) - .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) - expect(Object.keys(relation!.metadata?.bigObject)).toHaveLength(50) - expect(relation!.metadata?.longString).toHaveLength(10_000) - }) - - it('should refuse a relation metadata array over the indexing bound, by name', async () => { - // Arrange - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - - // Act - const err = await brain - .relate({ - from: entity1Id, - to: entity3Id, - type: 'relatedTo', - metadata: { bigArray: new Array(overTheBound).fill('item') } - }) - .catch((e: any) => e) - - // Assert — the field, the length and the bound, on the error and in the - // message, so a handler can report or repair without parsing prose. - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('bigArray') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.message).toContain('bigArray') - expect(err.message).toContain(String(overTheBound)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - - // Refused means not written: no relation of this shape exists. - const relations = await brain.related({ from: entity1Id }) - expect(relations.some(r => r.to === entity3Id && r.metadata?.bigArray)).toBe(false) + expect(relation!.metadata?.bigArray).toHaveLength(100) }) it('should handle special characters in metadata', async () => { diff --git a/tests/unit/brainy/update.test.ts b/tests/unit/brainy/update.test.ts index ec5f3fff..19fdad19 100644 --- a/tests/unit/brainy/update.test.ts +++ b/tests/unit/brainy/update.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -356,88 +355,36 @@ describe('Brainy.update()', () => { expect(final!.metadata.counter).toBeLessThanOrEqual(10) }) - // THE INDEXABLE-ARRAY BOUND, from update()'s side. This case used to write - // a 1000-element array through update() and assert it came back. That - // shape is refused at the write door now — an array field mints one - // posting per element, so an unbounded array is an unbounded write — so - // the case pins BOTH halves of the law that replaced it. Every length - // derives from MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant. - it('should handle a large scalar metadata update', async () => { + it('should handle very large metadata updates', async () => { // Arrange const id = await brain.add(createAddParams({ data: 'Large metadata test', type: 'thing' })) - - // Large in every dimension EXCEPT array length: a long string, many - // fields, deep nesting, and an array sitting exactly ON the bound. + const largeMetadata = { - atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), + bigArray: new Array(1000).fill('item'), bigObject: Object.fromEntries( Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(10_000), deepNesting: Array(10).fill(null).reduce( (acc) => ({ nested: acc }), { value: 'deep' } ) } - + // Act await brain.update({ id, metadata: largeMetadata, merge: false }) - - // Assert — the payload comes back whole, first element to last + + // Assert const updated = await brain.get(id) expect(updated).not.toBeNull() - expect(updated!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - expect(updated!.metadata.atTheBound[0]).toBe('item0') - expect(updated!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) - .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) + expect(updated!.metadata.bigArray).toHaveLength(1000) expect(Object.keys(updated!.metadata.bigObject)).toHaveLength(100) - expect(updated!.metadata.longString).toHaveLength(10_000) - - // ...including the deep nest, walked to the bottom. - let cursor: any = updated!.metadata.deepNesting - for (let depth = 0; depth < 10; depth++) cursor = cursor.nested - expect(cursor.value).toBe('deep') - }) - - it('should refuse an update whose metadata array is over the indexing bound, by name', async () => { - // Arrange - const id = await brain.add(createAddParams({ - data: 'Large metadata test', - type: 'thing', - metadata: { keep: 'me' } - })) - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - - // Act - const err = await brain - .update({ - id, - metadata: { bigArray: new Array(overTheBound).fill('item') }, - merge: false - }) - .catch((e: any) => e) - - // Assert — the field, the length and the bound, on the error and in the - // message, so a handler can report or repair without parsing prose. - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('bigArray') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.message).toContain('bigArray') - expect(err.message).toContain(String(overTheBound)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - - // Refused means unchanged: the row still carries what it had before. - const unchanged = await brain.get(id) - expect(unchanged!.metadata.keep).toBe('me') - expect(unchanged!.metadata.bigArray).toBeUndefined() }) it('should preserve entity ID during update', async () => { diff --git a/tests/unit/db/generationStore-commit-guard.test.ts b/tests/unit/db/generationStore-commit-guard.test.ts deleted file mode 100644 index d449f8ef..00000000 --- a/tests/unit/db/generationStore-commit-guard.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @module tests/unit/db/generationStore-commit-guard - * @description Pins the commit-order guard on - * `GenerationStore.commitTransaction()` (`src/db/generationStore.ts`). - * - * `reservedGensAsc()`'s own doc comment states an invariant it never - * enforced: pending single-op generations are always greater than every - * committed one, because the store's only two sanctioned callers — - * `Brainy.transact()` and `Brainy.compactHistory()` — flush the pending tier - * before committing. Nothing stopped a caller from invoking - * `commitTransaction()` directly while single-ops were still buffered: the - * fresh commit would land in `committedRanges` ABOVE those lower, - * still-pending generations, so the committed-then-pending concatenation - * `reservedGensAsc()` yields is no longer ascending — and `resolveManyAt` - * (which walks committed ranges before pending ones) would silently report a - * WRONG before-image for a point-in-time read. `commitTransaction()` now - * refuses loudly (`PendingSingleOpsUnflushedError`) instead of assuming. - * - * Four pins: - * 1. A direct `commitTransaction()` call while single-ops are pending throws - * and commits NOTHING. - * 2. The same commit succeeds once the pending tier is flushed first. - * 3. `Brainy.transact()` — which already flushes first — is unaffected - * (mirrors `tests/unit/db/generation-chain.test.ts`'s `seedX()`/`bumpX()` - * transact pin: add, then transact-update, generation advances by one - * each time, the update lands). - * 4. `reservedGensAsc()` stays ascending across a real add+transact+delete - * workload — proven by point-in-time reads (`asOf`) staying correct - * throughout, which is exactly what an ordering break would corrupt. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { - GenerationStore, - GENERATIONS_PREFIX, - MANIFEST_PATH -} from '../../../src/db/generationStore.js' -import { PendingSingleOpsUnflushedError } from '../../../src/db/errors.js' -import { Brainy } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' -import { createTestConfig, generateTestVector } from '../../helpers/test-factory.js' - -/** Precomputed embedding so Brainy-level adds skip the (slow) embedding model — - * these tests exercise the generation layer, not semantics. */ -const VEC = generateTestVector() - -// Entity ids must be UUID-shaped (the sharded storage layout derives the -// shard from the UUID hex) — same fixture convention as generationStore.test.ts. -const ID_A = '00000000-0000-4000-8000-0000000000aa' -const ID_B = '00000000-0000-4000-8000-0000000000bb' - -/** Stored-metadata fixture in the canonical shape the live write paths use - * (matches generationStore.test.ts's fixture exactly). */ -function metadataFixture(version: number): Record { - return { - noun: NounType.Document, - subtype: 'note', - data: `payload-v${version}`, - version, - createdAt: 1000, - updatedAt: 1000 + version, - _rev: version - } -} - -describe('db/GenerationStore — commitTransaction pending-tier guard (store level)', () => { - let storage: MemoryStorage - let store: GenerationStore - - beforeEach(async () => { - storage = new MemoryStorage() - await storage.init() - store = new GenerationStore(storage) - await store.open() - }) - - /** Buffer one single-op generation via commitSingleOp WITHOUT flushing — - * the pending tier that must be drained before commitTransaction(). */ - async function pendingSingleOp(id: string, version: number): Promise { - const { generation } = await store.commitSingleOp({ - touched: { nouns: [id] }, - execute: async () => { - await storage.saveNounMetadata(id, metadataFixture(version)) - } - }) - return generation - } - - /** A direct transact commit — exactly what a caller bypassing - * Brainy.transact()'s flush-first step would issue. */ - function directCommit(id: string, version: number): Promise<{ generation: number; timestamp: number }> { - return store.commitTransaction({ - touched: { nouns: [id], verbs: [] }, - execute: async () => { - await storage.saveNounMetadata(id, metadataFixture(version)) - } - }) - } - - it('PIN 1: refuses a direct commitTransaction() while single-ops are pending, and commits NOTHING', async () => { - const g1 = await pendingSingleOp(ID_A, 1) - expect(g1).toBe(1) - expect(store.committedGeneration()).toBe(0) // nothing flushed to disk yet - - let caught: unknown - try { - await directCommit(ID_B, 1) - expect.unreachable('should have thrown PendingSingleOpsUnflushedError') - } catch (err) { - caught = err - } - expect(caught).toBeInstanceOf(PendingSingleOpsUnflushedError) - expect((caught as PendingSingleOpsUnflushedError).pendingCount).toBe(1) - - // Nothing committed: the head + committed ranges are unchanged, and the - // counter never advanced for the refused attempt (the guard fires before - // a generation is even reserved). - expect(store.committedGeneration()).toBe(0) - expect(store.generation()).toBe(1) // still just the pending single-op's gen - expect(await storage.readRawObject(MANIFEST_PATH)).toBeNull() - // The guard fires BEFORE a generation is reserved (`gen = ++this.counter` - // never runs), so the refused attempt's would-be directory (generation 2, - // the next number after the pending single-op's 1) was never created. - expect(await storage.listRawObjects(`${GENERATIONS_PREFIX}/2`)).toEqual([]) - - // The refused write never touched canonical storage. - expect((await storage.readNounRaw(ID_B)).metadata).toBeNull() - - // The pending tier itself is untouched by the refused attempt — flushing - // now still commits the ORIGINAL single-op cleanly. - await store.flushPendingSingleOps() - expect(store.committedGeneration()).toBe(1) - const atG0 = await store.resolveAt('noun', ID_A, 0) - expect(atG0).toEqual({ source: 'absent' }) // the create sentinel before g1's write - }) - - it('PIN 2: the same commit succeeds once the pending tier is flushed first', async () => { - await pendingSingleOp(ID_A, 1) - await expect(directCommit(ID_B, 1)).rejects.toBeInstanceOf(PendingSingleOpsUnflushedError) - - await store.flushPendingSingleOps() - expect(store.committedGeneration()).toBe(1) - - const { generation } = await directCommit(ID_B, 1) - expect(generation).toBe(2) - expect(store.committedGeneration()).toBe(2) - expect((await storage.readNounRaw(ID_B)).metadata).toMatchObject({ version: 1 }) - }) -}) - -describe('Brainy public API — commitTransaction pending-tier guard is behavior-neutral', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy(createTestConfig()) - await brain.init() - }) - afterEach(async () => { - await brain.close() - }) - - it('PIN 3: Brainy.transact() still commits normally over pending single-ops (mirrors generation-chain.test.ts\'s seedX()/bumpX() transact pin)', async () => { - const store = (brain as any).generationStore as GenerationStore - // Relative, not absolute: under the adopt-at-open default the open-time - // baseline backfill takes a generation of its own (see - // bounded-chains.test.ts's identical note), so the first user add is not - // necessarily generation 1. - const baseGen = brain.generation() - const baseCommitted = store.committedGeneration() - - const id = await brain.add({ - data: 'x', - type: NounType.Document, - subtype: 'note', - metadata: { v: 1 }, - vector: VEC - }) - // The add is a pending single-op generation — NOT yet flushed. - expect(brain.generation()).toBe(baseGen + 1) - expect(store.committedGeneration()).toBe(baseCommitted) - - // Brainy.transact() flushes the pending tier FIRST (src/brainy.ts: - // `await this.generationStore.flushPendingSingleOps()`, immediately - // before its `generationStore.commitTransaction()` call), so the guard - // never fires on this path — same shape as generation-chain.test.ts's - // seedX() (add) → bumpX() (transact update) → generation advances by one. - const db = await brain.transact([{ op: 'update', id, metadata: { v: 2 } }]) - await db.release() - - expect(brain.generation()).toBe(baseGen + 2) - expect(store.committedGeneration()).toBe(baseGen + 2) // the flushed add + the transact update - const entity = (await brain.get(id)) as any - expect(entity.metadata.v).toBe(2) - }) - - it('PIN 4: reservedGensAsc() stays ascending across a real add+transact+delete workload — point-in-time reads stay correct', async () => { - const store = (brain as any).generationStore as GenerationStore - const baseGen = brain.generation() - const baseCommitted = store.committedGeneration() - - const idX = await brain.add({ - data: 'x', - type: NounType.Document, - subtype: 'note', - metadata: { v: 1 }, - vector: VEC - }) - expect(brain.generation()).toBe(baseGen + 1) // pending (un-flushed) - - const idY = await brain.add({ - data: 'y', - type: NounType.Document, - subtype: 'note', - metadata: { v: 1 }, - vector: VEC - }) - // Pin right after BOTH adds — before the transact update — so X reads v1 - // and Y still exists at this pin, unlike the live head after the rest of - // the workload runs. - const pinAfterBothAdds = brain.generation() - expect(pinAfterBothAdds).toBe(baseGen + 2) // ALSO pending — two un-flushed single-ops - expect(store.committedGeneration()).toBe(baseCommitted) - - // A transact() flushes baseGen+1 and baseGen+2 first, then commits its - // own update as baseGen+3. If committed-vs-pending ordering ever broke, - // this is exactly the step that would land a commit ABOVE still-pending - // generations. - const db = await brain.transact([{ op: 'update', id: idX, metadata: { v: 3 } }]) - await db.release() - expect(brain.generation()).toBe(baseGen + 3) - expect(store.committedGeneration()).toBe(baseGen + 3) - - // A single-op delete, pending again (un-flushed). - await brain.remove(idY) - expect(brain.generation()).toBe(baseGen + 4) - - // A point-in-time read pinned right after the two adds (before the - // transact update) must see X's PRE-update value and Y still present. - // This is precisely what resolveManyAt/resolveAt get WRONG if committed - // and pending generations were ever interleaved out of ascending order. - const past = await brain.asOf(pinAfterBothAdds) - const xAtPin = (await past.get(idX)) as any - expect(xAtPin?.metadata?.v).toBe(1) - const yAtPin = (await past.get(idY)) as any - expect(yAtPin?.metadata?.v).toBe(1) // not yet removed, as of this pin - await past.release() - - // Live state reflects every later write, in the right order. - const xNow = (await brain.get(idX)) as any - expect(xNow.metadata.v).toBe(3) - expect(await brain.get(idY)).toBeNull() - }) -}) diff --git a/tests/unit/indexes/columnStore/column-store-mixed-kind.test.ts b/tests/unit/indexes/columnStore/column-store-mixed-kind.test.ts deleted file mode 100644 index 1ce21d1f..00000000 --- a/tests/unit/indexes/columnStore/column-store-mixed-kind.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * @module column-store-mixed-kind.test - * @description Typed posting lists: one field, several value KINDS, each - * answerable on its own. - * - * The behaviour these pin replaced a first-writer type freeze. The first value - * a field ever saw fixed that field's type; every later value of another kind - * was coerced to it, and when coercion failed — `Number('electronics')` — the - * value was dropped from the index with no error at all. The row stayed - * readable by id and by vector and vanished from every equality filter on the - * field. These tests therefore care about ORDER: strings-then-numbers and - * numbers-then-strings have to behave identically, because neither writer owns - * the field. - * - * Kinds never coerce into one another at query time either. `5` and `'5'` are - * different values and match different rows. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { ColumnStore } from '../../../../src/indexes/columnStore/ColumnStore.js' -import { MemoryStorage } from '../../../../src/storage/adapters/memoryStorage.js' -import { EntityIdMapper } from '../../../../src/utils/entityIdMapper.js' - -describe('ColumnStore — typed posting lists per (field, kind)', () => { - let storage: MemoryStorage - let idMapper: EntityIdMapper - let store: ColumnStore - - beforeEach(async () => { - storage = new MemoryStorage() - await storage.init() - idMapper = new EntityIdMapper({ storage, storageKey: 'test:idMapper' }) - await idMapper.init() - - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - }) - - afterEach(async () => { - await store.close() - }) - - /** Resolve a filter to the sorted UUIDs it matched. */ - const uuidsOf = async (field: string, value: unknown): Promise => { - const bitmap = await store.filter(field, value) - return Array.from(bitmap) - .map((id) => idMapper.getUuid(Number(id))) - .filter((u): u is string => u !== undefined) - .sort() - } - - describe('equality answers on the query value’s own kind', () => { - it('serves numbers written AFTER strings on the same field', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'furniture' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('n3')), { category: 7 }) - - // The numbers are in the index, though a string got there first. - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - expect(await uuidsOf('category', 7)).toEqual(['n3']) - // And the strings did not move. - expect(await uuidsOf('category', 'electronics')).toEqual(['s1']) - expect(await uuidsOf('category', 'furniture')).toEqual(['s2']) - }) - - it('serves strings written AFTER numbers on the same field', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'electronics' }) - - // 'electronics' would have become NaN and been dropped under the freeze. - expect(await uuidsOf('category', 'electronics')).toEqual(['s1', 's2']) - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - }) - - it('does not coerce a number query into the string postings, or back', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('num')), { code: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('str')), { code: '5' }) - - expect(await uuidsOf('code', 5)).toEqual(['num']) - expect(await uuidsOf('code', '5')).toEqual(['str']) - }) - - it('serves booleans mixed into a field that already holds strings and numbers', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { flag: 'yes' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { flag: 1 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { flag: true }) - store.addEntity(BigInt(idMapper.getOrAssign('b2')), { flag: false }) - - expect(await uuidsOf('flag', true)).toEqual(['b1']) - expect(await uuidsOf('flag', false)).toEqual(['b2']) - // `true` stores as 1 internally; that is an encoding, not a value. - expect(await uuidsOf('flag', 1)).toEqual(['n1']) - expect(await uuidsOf('flag', 'yes')).toEqual(['s1']) - }) - - it('answers nothing — not something coerced — for a kind the field never held', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - - expect(await uuidsOf('category', 5)).toEqual([]) - expect(await uuidsOf('category', true)).toEqual([]) - }) - - it('holds every kind across a flush, not just the one in the tail buffer', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - await store.flush() - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - - expect(await uuidsOf('category', 'electronics')).toEqual(['s1', 's2']) - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - }) - }) - - describe('range filters read the numeric postings', () => { - it('ranges over the numeric subset of a mixed field, ignoring its strings', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('cheap')), { price: 100 }) - store.addEntity(BigInt(idMapper.getOrAssign('mid')), { price: 500 }) - store.addEntity(BigInt(idMapper.getOrAssign('dear')), { price: 900 }) - store.addEntity(BigInt(idMapper.getOrAssign('unpriced')), { price: 'on request' }) - await store.flush() - - const inRange = await store.rangeQuery('price', 200, 1000) - const uuids = Array.from(inRange) - .map((id) => idMapper.getUuid(Number(id))) - .sort() - expect(uuids).toEqual(['dear', 'mid']) - }) - - it('an unbounded range still reports every kind — it is the “has a value” probe', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { mixed: 42 }) - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { mixed: 'text' }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { mixed: true }) - await store.flush() - - const anyValue = await store.rangeQuery('mixed') - const uuids = Array.from(anyValue) - .map((id) => idMapper.getUuid(Number(id))) - .sort() - expect(uuids).toEqual(['b1', 'n1', 's1']) - }) - }) - - describe('the index reports what a field actually holds', () => { - it('names every kind present, not the one that got there first', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - expect(store.getFieldKinds('category')).toEqual(['string']) - - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { category: true }) - expect(store.getFieldKinds('category')).toEqual(['number', 'string', 'boolean']) - - // And the field is still ONE field by name. - expect(store.getIndexedFields()).toEqual(['category']) - expect(store.hasField('category')).toBe(true) - }) - - it('reports an unknown field as holding nothing', () => { - expect(store.getFieldKinds('never-written')).toEqual([]) - }) - }) - - describe('an integer column widens rather than rounding', () => { - it('keeps a non-integer written after integers as itself', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('a')), { score: 4 }) - store.addEntity(BigInt(idMapper.getOrAssign('b')), { score: 4.5 }) - store.addEntity(BigInt(idMapper.getOrAssign('c')), { score: 5 }) - await store.flush() - - // 4.5 used to round to 5 and answer `score === 5` alongside c. - expect(await uuidsOf('score', 4.5)).toEqual(['b']) - expect(await uuidsOf('score', 5)).toEqual(['c']) - expect(await uuidsOf('score', 4)).toEqual(['a']) - }) - }) - - describe('close then reopen', () => { - it('keeps every typed posting, on the same storage', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { category: true }) - store.addEntity(BigInt(idMapper.getOrAssign('f1')), { score: 1.5 }) - await store.flush() - await store.close() - - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - - expect(store.getFieldKinds('category')).toEqual(['number', 'string', 'boolean']) - expect(await uuidsOf('category', 'electronics')).toEqual(['s1']) - expect(await uuidsOf('category', 5)).toEqual(['n1']) - expect(await uuidsOf('category', true)).toEqual(['b1']) - expect(await uuidsOf('score', 1.5)).toEqual(['f1']) - }) - - it('accepts new values of every kind after the reopen', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - await store.flush() - await store.close() - - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { category: false }) - await store.flush() - - expect(await uuidsOf('category', 'electronics')).toEqual(['s1', 's2']) - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - expect(await uuidsOf('category', false)).toEqual(['b1']) - }) - - it('opens an index written by the pre-typed-postings shape and reads it unchanged', async () => { - // A single-kind field is byte-identical to what the old writer produced: - // one manifest at `_column_index//MANIFEST.json`, no kind - // subdirectory anywhere. That IS the old on-disk shape, so proving the - // new reader serves it proves an old index still opens. - store.addEntity(BigInt(idMapper.getOrAssign('a')), { status: 'active' }) - store.addEntity(BigInt(idMapper.getOrAssign('b')), { status: 'archived' }) - await store.flush() - - const keys = await (storage as unknown as { - listObjectsUnderPath: (prefix: string) => Promise - }).listObjectsUnderPath('_column_index/') - expect(keys.some((k) => k.includes('/k/'))).toBe(false) - - await store.close() - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - - expect(store.getFieldKinds('status')).toEqual(['string']) - expect(await uuidsOf('status', 'active')).toEqual(['a']) - }) - }) -}) diff --git a/tests/unit/release/wall-entry.test.ts b/tests/unit/release/wall-entry.test.ts deleted file mode 100644 index 8bf9d357..00000000 --- a/tests/unit/release/wall-entry.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -/** - * scripts/wall-entry.mjs — the mechanical releases-wall entry. - * - * The script's only real interface is its CLI (it has no importable - * exports by design — one door, no parallel API to drift from it), so - * these tests spawn it exactly as scripts/release.sh does: as a child - * process, against a fixture CHANGELOG and a throwaway local bare repo - * standing in for git@source.soulcraft.com:soulcraftlabs/releases.git - * (--remote) plus a throwaway cache directory (--cache-dir) standing in - * for ~/.cache/soulcraft-releases — never the real remote, never the - * real developer cache. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const SCRIPT = join(process.cwd(), 'scripts/wall-entry.mjs') - -/** Run the script and capture the outcome without throwing on a non-zero exit. */ -function run(args: string[], cwd: string): { status: number; stdout: string; stderr: string } { - try { - const stdout = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }) - return { status: 0, stdout, stderr: '' } - } catch (err: any) { - return { status: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } - } -} - -function git(args: string[], cwd: string): string { - return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim() -} - -const CHANGELOG_HEADER = '# Changelog\n\nAll notable changes, in this fixture.\n' - -/** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */ -function buildChangelog(entries: Array<{ version: string; date: string; bullets: string[] }>): string { - const body = entries - .map( - (e) => - `### [${e.version}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/vX...v${e.version}) (${e.date})\n\n` + - e.bullets.map((b) => `- ${b} (abc1234)`).join('\n') + - '\n', - ) - .join('\n') - return CHANGELOG_HEADER + '\n' + body -} - -function wallFile(product: string, entries: unknown[]): string { - return JSON.stringify({ product, entries }, null, 2) + '\n' -} - -const BASE_ENTRY = { - version: '10.4.11', - date: '2026-09-02', - headline: 'A faster open', - items: ['A faster open.'], - url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11', - thumb: null, -} - -/** A throwaway bare repo standing in for the real soulcraftlabs/releases remote. */ -function initBareRemote(): string { - const remoteDir = mkdtempSync(join(tmpdir(), 'wall-remote-')) - execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir]) - return remoteDir -} - -/** Seed the bare remote with an initial .json, via a throwaway clone. */ -function seedRemote(remoteDir: string, product: string, entries: unknown[]): void { - const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-')) - execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) - git(['config', 'user.email', 'seed@example.com'], seedDir) - git(['config', 'user.name', 'Seed'], seedDir) - writeFileSync(join(seedDir, `${product}.json`), wallFile(product, entries)) - git(['add', `${product}.json`], seedDir) - git(['commit', '-m', 'seed'], seedDir) - git(['push', 'origin', 'main'], seedDir) - rmSync(seedDir, { recursive: true, force: true }) -} - -/** Read .json back out of the bare remote's main tip, via a throwaway clone. */ -function readRemote(remoteDir: string, product: string): any { - const readDir = mkdtempSync(join(tmpdir(), 'wall-read-')) - execFileSync('git', ['clone', remoteDir, readDir], { stdio: 'ignore' }) - const data = JSON.parse(readFileSync(join(readDir, `${product}.json`), 'utf8')) - rmSync(readDir, { recursive: true, force: true }) - return data -} - -/** Reject every push — stands in for any push failure (including a genuine - * non-fast-forward raced by a concurrent release rail), which this script - * treats identically: refuse loudly, name the cure, touch nothing further. */ -function makeRemoteRejectPushes(remoteDir: string): void { - const hookPath = join(remoteDir, 'hooks', 'pre-receive') - writeFileSync(hookPath, '#!/bin/sh\necho "remote: simulated push rejection" >&2\nexit 1\n') - chmodSync(hookPath, 0o755) -} - -let dir: string -let remoteDir: string -let cacheDir: string - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-')) - remoteDir = initBareRemote() - cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases') -}) - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - rmSync(remoteDir, { recursive: true, force: true }) - rmSync(cacheDir, { recursive: true, force: true }) -}) - -describe('wall-entry.mjs — generate + publish', () => { - it('derives headline from the first bullet and items from every bullet, hashes stripped, and pushes it to the remote', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - writeFileSync( - join(dir, 'CHANGELOG.md'), - buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]), - ) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/wrote v10\.4\.12.*pushed/i) - - const wall = readRemote(remoteDir, 'open-brainy') - expect(wall.entries).toHaveLength(2) - expect(wall.entries[0]).toEqual({ - version: '10.4.12', - date: '2026-09-03', - headline: 'fix(wall): mechanize the entry', - items: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'], - url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.12', - thumb: null, - }) - // the older entry stays put, still second - expect(wall.entries[1].version).toBe('10.4.11') - }) - - it('prepends newest-first — the new entry lands at index 0 ahead of every existing one', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }])) - - run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) - - const wall = readRemote(remoteDir, 'open-brainy') - expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10']) - }) - - it('replaces an entry with the same version instead of duplicating it — idempotent re-runs', () => { - seedRemote(remoteDir, 'open-brainy', [ - { ...BASE_ENTRY, headline: 'stale headline, pre-fix' }, - { ...BASE_ENTRY, version: '10.4.10' }, - ]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: the corrected headline'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/replaced v10\.4\.11/i) - - const wall = readRemote(remoteDir, 'open-brainy') - expect(wall.entries).toHaveLength(2) // not 3 — replaced, not duplicated - expect(wall.entries[0].version).toBe('10.4.11') - expect(wall.entries[0].headline).toBe('fix: the corrected headline') - expect(wall.entries[1].version).toBe('10.4.10') - }) - - it('a re-run with byte-identical content commits nothing and still succeeds', () => { - // headline always equals items[0] for a derived entry, so this fixture - // (unlike BASE_ENTRY, whose headline/items intentionally diverge for the - // shape-only tests below) has to keep the two in lockstep to ever roundtrip. - const stableEntry = { ...BASE_ENTRY, headline: 'A faster open.', items: ['A faster open.'] } - seedRemote(remoteDir, 'open-brainy', [stableEntry]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['A faster open.'] }])) - const before = readRemote(remoteDir, 'open-brainy') - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/nothing to commit/i) - expect(readRemote(remoteDir, 'open-brainy')).toEqual(before) - }) - - it('derives the public package-page permalink for the product engine (private repo, never null)', () => { - seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: 'https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.5' }]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) - - const result = run( - ['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - - const wall = readRemote(remoteDir, 'brainy') - expect(wall.entries[0].url).toBe('https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.6') - expect(wall.entries[0].thumb).toBeNull() - }) - - it('refuses a product with no permalink pattern, naming the cure', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['feat: first'] }])) - - const result = run(['--product', 'mystery', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) - expect(result.status).not.toBe(0) - expect(result.stderr).toMatch(/no permalink pattern for product "mystery"/) - expect(result.stderr).toMatch(/never carry url: null/) - }) - - it('refuses when the CHANGELOG has no entry yet for the target version, and touches no remote', () => { - seedRemote(remoteDir, 'open-brainy', []) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) - const beforeSha = git(['rev-parse', 'main'], remoteDir) - - const result = run( - ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/no CHANGELOG entry yet/i) - expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) - }) - - it('refuses by naming the cure when the remote cannot be cloned', () => { - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) - const noSuchRemote = join(tmpdir(), 'wall-remote-does-not-exist-' + Date.now()) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', noSuchRemote, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/cannot clone/i) - expect(result.stderr).toMatch(/cure:/i) - }) - - it('refuses by naming the cure, and touches no remote, when the fetched wall fails shape validation', () => { - const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-broken-')) - execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) - git(['config', 'user.email', 'seed@example.com'], seedDir) - git(['config', 'user.name', 'Seed'], seedDir) - writeFileSync( - join(seedDir, 'open-brainy.json'), - JSON.stringify({ product: 'open-brainy', entries: [{ version: '10.4.11', date: '2026-09-02', items: ['x'], url: null }] }, null, 2), - ) - git(['add', 'open-brainy.json'], seedDir) - git(['commit', '-m', 'seed broken'], seedDir) - git(['push', 'origin', 'main'], seedDir) - rmSync(seedDir, { recursive: true, force: true }) - const beforeSha = git(['rev-parse', 'main'], remoteDir) - - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/fails shape validation/i) - expect(result.stderr).toMatch(/missing key\(s\) headline/i) - expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) - }) - - it('refuses by naming the cure when the remote rejects the push (stands in for a raced non-fast-forward)', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - makeRemoteRejectPushes(remoteDir) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/push to .* failed/i) - expect(result.stderr).toMatch(/cure:/i) - }) - - it('refuses a cross-product write when the file\'s "product" field does not match --product', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-mismatch-')) - execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) - git(['config', 'user.email', 'seed@example.com'], seedDir) - git(['config', 'user.name', 'Seed'], seedDir) - const corrupted = JSON.parse(readFileSync(join(seedDir, 'open-brainy.json'), 'utf8')) - corrupted.product = 'brainy' - writeFileSync(join(seedDir, 'open-brainy.json'), JSON.stringify(corrupted, null, 2) + '\n') - git(['add', 'open-brainy.json'], seedDir) - git(['commit', '-m', 'corrupt product field'], seedDir) - git(['push', 'origin', 'main'], seedDir) - rmSync(seedDir, { recursive: true, force: true }) - - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/product "brainy".*--product "open-brainy"/i) - }) -}) - -describe('wall-entry.mjs — --dry-run', () => { - it('prints the entry and the target path, and touches neither the cache dir nor the remote', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: a dry run'] }])) - const beforeSha = git(['rev-parse', 'main'], remoteDir) - - const result = run( - ['--dry-run', '--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/would write to/i) - expect(result.stdout).toMatch(/"version": "10\.4\.12"/) - expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) - }) -}) - -describe('wall-entry.mjs — --check', () => { - it('passes a well-formed, newest-first file with no duplicates', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/OK/) - }) - - it('passes a file where "thumb" is entirely absent (optional per the HQ contract)', () => { - const { thumb, ...noThumb } = BASE_ENTRY as any - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [noThumb])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(0) - }) - - it('catches a missing entry key', () => { - const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'] } // no "url" - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/missing key\(s\) url/) - }) - - it('catches an unexpected top-level key (e.g. the retired "history" field)', () => { - const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY])) - raw.history = 'retired field' - writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw)) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/unexpected key\(s\) history/) - }) - - it('catches entries that are not newest-first', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, version: '10.4.10' }, BASE_ENTRY])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/not newest-first/) - }) - - it('catches a duplicate version even with identical entries', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/duplicate version 10\.4\.11/) - }) - - it('catches an empty items array', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, items: [] }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/"items" must be a non-empty array/) - }) - - it('catches a malformed date', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, date: '09/03/2026' }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/"date" must be a YYYY-MM-DD string/) - }) -}) diff --git a/tests/unit/test-suite-coverage-guard.test.ts b/tests/unit/test-suite-coverage-guard.test.ts index f12b0587..d4d268ac 100644 --- a/tests/unit/test-suite-coverage-guard.test.ts +++ b/tests/unit/test-suite-coverage-guard.test.ts @@ -4,8 +4,7 @@ * config (so it never runs and gives false coverage confidence — the exact drift * that left ~27 test files un-run before 8.0). Every `*.test.ts` must either match * a gate config (`tests/unit/**`, `tests/integration/**`, `*.unit.test.ts`, - * `*.integration.test.ts`, or the perf lane's `tests/configs/vitest.perf.config.ts` - * — see PERF_LANE_FILES below) or be explicitly listed in MANUAL_ONLY below. + * `*.integration.test.ts`) or be explicitly listed in MANUAL_ONLY below. */ import { describe, it, expect } from 'vitest' import { readdirSync } from 'node:fs' @@ -25,12 +24,10 @@ function allTestFiles(dir: string, out: string[] = []): string[] { } /** - * Test files INTENTIONALLY excluded from every automated gate — conformance - * suites invoked directly, and checks that need real resources (network, - * unusual scale) no CI lane provides. Wall-clock/scale benchmarks that DO - * run automatically belong to the perf lane (PERF_LANE_FILES / inGate - * below), not here. Every entry is a conscious decision — a NEW orphan not - * listed here fails the guard below. + * Test files INTENTIONALLY excluded from the unit/integration gate: benchmarks, + * scale/perf measurements, package-size checks, and real-model-load checks. They + * are run manually (slow / need real resources), not in CI. Every entry is a + * conscious decision — a NEW orphan not listed here fails the guard below. */ const MANUAL_ONLY = new Set([ // Conformance suites run as an explicit gate stage (both engines run them @@ -43,11 +40,15 @@ const MANUAL_ONLY = new Set([ // The sparse-store cut's shared operator rows (both engines run these): // explicit conformance-gate invocation, like its siblings. 'tests/conformance/sparse-store-cut.test.ts', - // NOT the perf lane: no wall-clock/scale assertion, so it does not belong - // in tests/configs/vitest.perf.config.ts's include list — genuinely run - // by hand only. + 'tests/api/performance-benchmarks.test.ts', 'tests/critical-neural-validation.test.ts', + 'tests/critical-performance-benchmark.test.ts', + 'tests/model-loading.test.ts', 'tests/package-size-breakdown.test.ts', + 'tests/package-size-limit.test.ts', + 'tests/performance/graph-scale-performance.test.ts', + 'tests/performance/triple-intelligence-scale.test.ts', + 'tests/performance/typeAware.bench.test.ts', // Cross-engine field-addressing conformance suite: pinned bit-for-bit against // the native accelerator's implementation of the SAME contract, and invoked // directly (`npx vitest run tests/conformance/namespace-law.test.ts`), never @@ -58,21 +59,6 @@ const MANUAL_ONLY = new Set([ 'tests/conformance/namespace-law.test.ts' ]) -/** - * The perf lane's own gate: `tests/configs/vitest.perf.config.ts`, run by - * `npm run test:perf`. Mirrors that config's `include` list — kept in sync - * by inspection, the same convention that config uses against the root - * gate's exclude list (see its own header comment). A file that runs here - * is GATED, not manual: it belongs in this set (or the `tests/performance/` - * prefix below), never in MANUAL_ONLY. - */ -const PERF_LANE_FILES = new Set([ - 'tests/critical-performance-benchmark.test.ts', - 'tests/api/performance-benchmarks.test.ts', - 'tests/package-size-limit.test.ts', - 'tests/model-loading.test.ts' -]) - function inGate(rel: string): boolean { return ( rel.startsWith('tests/unit/') || @@ -81,11 +67,7 @@ function inGate(rel: string): boolean { // ('tests/lifecycle/**/*.test.ts'; see tests/lifecycle/README.md). rel.startsWith('tests/lifecycle/') || rel.endsWith('.unit.test.ts') || - rel.endsWith('.integration.test.ts') || - // The perf lane (see PERF_LANE_FILES above) — mirrors - // tests/configs/vitest.perf.config.ts's `tests/performance/**` glob. - rel.startsWith('tests/performance/') || - PERF_LANE_FILES.has(rel) + rel.endsWith('.integration.test.ts') ) } diff --git a/tests/unit/utils/metadataIndex-array-bound.test.ts b/tests/unit/utils/metadataIndex-array-bound.test.ts deleted file mode 100644 index a96ae1d6..00000000 --- a/tests/unit/utils/metadataIndex-array-bound.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-array-bound - * @description THE INDEXABLE-ARRAY BOUND — a law with a name and a refusal, - * not a `continue`. - * - * THE DEFECT. An array-valued metadata field indexes one posting per element, - * so the index has always carried a ceiling. It was 10, and it was applied by a - * bare `continue` deep inside field extraction: - * - * if (Array.isArray(value) && value.length > 10) continue - * - * A row whose `tags` array held ELEVEN entries therefore had that field skipped - * entirely — no posting, no error, no warning. The row then failed to match - * every filtered search on `tags`, including `{ tags: 'a-tag-it-really-has' }`, - * and the caller had no way to tell that from "no row matches". Eleven tags is - * not an exotic shape; the eleventh tag made the row invisible. - * - * THE LAW. Arrays of scalars index up to {@link MAX_INDEXED_ARRAY_LENGTH}, - * hardcoded (the zero-config law: no knob), which clears every legitimate - * multi-value field — tags, authors, keyword lists — and stays below the - * narrowest embedding this engine meets (384 dimensions). Above it the WRITE - * IS REFUSED by name — `MetadataArrayTooLargeError`, carrying the field, the - * length and the bound — at `add`, `update`, `relate` and `updateRelation` - * alike. Nothing is skipped in silence. - * - * THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an - * older engine under the old rule and read back by a rebuild, a catch-up fold - * or a remove. Refusing there would make an existing store un-rebuildable — so - * the row is admitted and the skipped field is NARRATED. Both sides are pinned. - */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { NounType, VerbType } from '../../../src/types/graphTypes' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { resolveEntityId } from '../../../src/utils/idNormalization' -import { prodLog } from '../../../src/utils/logger' - -/** `n` distinct scalar tags. */ -function tags(n: number, prefix = 't'): string[] { - return Array.from({ length: n }, (_, i) => `${prefix}${i}`) -} - -describe('the indexable-array bound', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - }) - - describe('BELOW the bound: the array indexes, every element of it', () => { - it('the eleven-element array that used to vanish is searchable', async () => { - // ELEVEN — one over the old silent limit, the whole shape of the defect. - await brain.add({ - id: 'eleven', - data: 'a row with eleven tags', - type: NounType.Document, - metadata: { tags: tags(11) }, - vector: [] - }) - - // Every element is a posting, including the eleventh. - for (const tag of tags(11)) { - const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('eleven')) - } - }) - - it('indexes right up to the bound — every element of it', async () => { - await brain.add({ - id: 'at-bound', - data: 'a row at the bound', - type: NounType.Document, - metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH) }, - vector: [] - }) - - // The first, the last, and one in the middle — all derived from the - // bound, so the case follows the constant wherever it moves. - for (const tag of ['t0', `t${MAX_INDEXED_ARRAY_LENGTH - 1}`, `t${Math.floor(MAX_INDEXED_ARRAY_LENGTH / 2)}`]) { - const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('at-bound')) - } - }) - - it('a nested bag\'s array indexes under its dotted address', async () => { - await brain.add({ - id: 'nested', - data: 'a row with a nested tag list', - type: NounType.Document, - metadata: { facets: { labels: tags(20, 'l') } }, - vector: [] - }) - const hits = await brain.find({ where: { 'facets.labels': 'l19' }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('nested')) - }) - }) - - describe('ABOVE the bound: the write is refused, by name', () => { - const OVER = MAX_INDEXED_ARRAY_LENGTH + 1 - - it('add() throws a typed error naming the field, the length and the bound', async () => { - const err = await brain - .add({ - id: 'too-many', - data: 'a row with too many tags', - type: NounType.Document, - metadata: { tags: tags(OVER) }, - vector: [] - } as any) - .catch((e: any) => e) - - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('tags') - expect(err.length).toBe(OVER) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.type).toBe('VALIDATION') - // The message carries all three, and names the cures. - expect(err.message).toContain('tags') - expect(err.message).toContain(String(OVER)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - expect(err.message).toContain('vector') - }) - - it('the refused row is not written at all — no half-indexed ghost', async () => { - await expect( - brain.add({ - id: 'refused', - data: 'refused', - type: NounType.Document, - metadata: { tags: tags(OVER) }, - vector: [] - } as any) - ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) - - expect(await brain.get('refused')).toBeNull() - const hits = await brain.find({ where: { tags: 't0' }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).not.toContain(resolveEntityId('refused')) - }) - - it('a 384-float embedding parked in the metadata bag is refused, not swallowed', async () => { - const err = await brain - .add({ - id: 'bag-vector', - data: 'an embedding in the wrong place', - type: NounType.Document, - metadata: { embedding: Array.from({ length: 384 }, (_, i) => i / 384) }, - vector: [] - } as any) - .catch((e: any) => e) - - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('embedding') - expect(err.length).toBe(384) - }) - - it('update() refuses it too', async () => { - await brain.add({ - id: 'grow', - data: 'starts small', - type: NounType.Document, - metadata: { tags: tags(3) }, - vector: [] - }) - await expect( - brain.update({ id: 'grow', metadata: { tags: tags(OVER) } } as any) - ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) - - // And the row keeps the values it had. - const hits = await brain.find({ where: { tags: 't1' }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('grow')) - }) - - it('relate() refuses it on a verb\'s metadata', async () => { - await brain.add({ id: 'a', data: 'a', type: NounType.Thing, vector: [] }) - await brain.add({ id: 'b', data: 'b', type: NounType.Thing, vector: [] }) - await expect( - brain.relate({ - from: 'a', - to: 'b', - type: VerbType.RelatedTo, - metadata: { tags: tags(OVER) } - } as any) - ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) - }) - - it('a nested oversize array is refused under its dotted address', async () => { - const err = await brain - .add({ - id: 'nested-over', - data: 'nested and too long', - type: NounType.Document, - metadata: { facets: { labels: tags(OVER, 'l') } }, - vector: [] - } as any) - .catch((e: any) => e) - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('facets.labels') - }) - }) - - describe('a row already on disk is admitted, and the skip is NARRATED', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('extraction over an old oversize row warns by field, length and bound', async () => { - const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) - const index = (brain as any).metadataIndex - - // The shape an older engine persisted: the write door never saw it, so - // this reaches extraction directly — exactly as a rebuild or a remove - // reading the row back would. - const fields = index.extractIndexableFields({ - metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH + 5), keep: 'me' } - }) - - // The oversize field contributes nothing... - expect(fields.filter((f: any) => f.field === 'tags')).toHaveLength(0) - // ...the rest of the row indexes normally — the row is not rejected... - expect(fields.some((f: any) => f.field === 'keep' && f.value === 'me')).toBe(true) - // ...and the skip is said out loud, with everything needed to act on it. - expect(warn).toHaveBeenCalled() - const said = warn.mock.calls.map((c: any[]) => String(c[0])).join('\n') - expect(said).toContain('tags') - expect(said).toContain(String(MAX_INDEXED_ARRAY_LENGTH + 5)) - expect(said).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - expect(said).toContain('NOT indexed') - }) - - it('an at-bound row on disk is indexed in full and says nothing', async () => { - const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) - const index = (brain as any).metadataIndex - - const fields = index.extractIndexableFields({ - metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH) } - }) - expect(fields.filter((f: any) => f.field === 'tags')).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - - const said = warn.mock.calls.map((c: any[]) => String(c[0])).join('\n') - expect(said).not.toContain('indexing bound') - }) - }) -}) diff --git a/tests/unit/utils/metadataIndex-sparse-range-collation.test.ts b/tests/unit/utils/metadataIndex-sparse-range-collation.test.ts deleted file mode 100644 index d6d00568..00000000 --- a/tests/unit/utils/metadataIndex-sparse-range-collation.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-sparse-range-collation - * @description RANGE QUERIES ON THE LEGACY SPARSE INDEX — order, or a refusal. - * Never a confidently ordered wrong answer. - * - * THE TWO RANGE PATHS. `getIdsForRange` routes a `gte` / `lt` / `between` two - * ways. The column store compares RAW values and is correct. The legacy sparse - * chunk index — the pre-7.20.0 fallback, still read for workspaces that have - * not been rebuilt — compared `normalizeValue()` output, and `normalizeValue` - * carries an escape hatch that destroys order on purpose: a string over 100 - * characters is replaced by a short hash so it can serve as a filesystem-safe - * key. Ordering hashes ranks rows by digest. - * - * THE DEFECT, IN TWO SHAPES. - * - * (a) A LONG BOUND against ordinary values. `where: { title: { gte: } }` collapsed the BOUND to `__HASH_…`, whose - * leading underscores sort below every letter — so a bound that should - * have excluded everything matched the entire field instead. This is the - * shape that reaches a caller who never stored a long value at all. - * - * (b) LONG VALUES in the index. A field whose values ran long was persisted - * hashed, so its order is not recoverable from this index at all. The old - * code compared the digests anyway and returned a subset chosen by hash. - * - * THE LAW. Bounds are normalized WITHOUT the hash escape hatch, so a long - * bound stays comparable — (a) is simply fixed. Where the persisted KEY is a - * hash, the order does not exist to be computed, and the query throws a typed - * `BrainyError('INVALID_QUERY')` naming the field and the cure — (b) is - * refused by name. Loud beats wrong. - * - * THE FIXTURE is a genuine legacy index: it is written through the same - * `ChunkManager` / `SparseIndex` doors a pre-7.20.0 engine wrote through, with - * keys normalized exactly as that engine normalized them, into a field the - * column store does not serve. The chunk WRITE path was removed in 11be039, so - * this is the only way the shape the read path exists for can be built. - * - * NOT CLAIMED HERE. The persisted keys are also lower-cased and trimmed by - * `normalizeValue`, so this path's string ranges are case-INSENSITIVE where - * the column store's are not. The raw values are not in the index to compare — - * that divergence is a property of the bytes on disk and it ends when the - * column store adopts the field. It is named in `getIdsFromChunksForRange`'s - * doc comment rather than papered over. - */ -import { describe, it, expect, beforeEach } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { NounType } from '../../../src/types/graphTypes' -import { SparseIndex, ChunkManager } from '../../../src/utils/metadataIndexChunking' -import { BrainyError } from '../../../src/errors/brainyError' - -/** The field the legacy index covers — deliberately never given to a row, so - * the column store never learns it and the sparse fallback is the only path. */ -const FIELD = 'legacyTitle' - -/** - * Write a legacy sparse index for `field` exactly as a pre-7.20.0 engine did: - * one chunk, keys normalized through the index's own `normalizeValue`, ids as - * roaring bitmaps, a zone map and a bloom filter over the chunk. - * - * @param brain - The live brain whose metadata index gains the legacy field. - * @param field - Field name to index. - * @param valueToIds - Raw value → the entity ids that carried it. - */ -async function writeLegacySparseIndex( - brain: any, - field: string, - valueToIds: Array<[string, string[]]> -): Promise { - const index = brain.metadataIndex - const chunkManager: ChunkManager = index.chunkManager - const sparseIndex = new SparseIndex(field) - - // The keys a pre-7.20.0 writer persisted: normalizeValue output, hash escape - // hatch and all. This is what makes the fixture the real shape. - const chunk = await chunkManager.createChunk(field) - for (const [value, ids] of valueToIds) { - const key = index.normalizeValue(value, field) - for (const id of ids) await chunkManager.addToChunk(chunk, key, id) - } - await chunkManager.saveChunk(chunk) - - sparseIndex.registerChunk( - { - chunkId: chunk.chunkId, - field, - valueCount: chunk.entries.size, - idCount: Array.from(chunk.entries.values()).reduce((s: number, b: any) => s + b.size, 0), - zoneMap: (chunkManager as any).calculateZoneMap(chunk), - lastUpdated: Date.now(), - splitThreshold: 80, - mergeThreshold: 20 - }, - chunkManager.createBloomFilter(chunk) - ) - - await index.saveSparseIndex(field, sparseIndex) -} - -/** A deterministic string of `n` characters starting with `lead`. */ -function longString(lead: string, n: number): string { - return lead + 'x'.repeat(n - lead.length) -} - -describe('legacy sparse index: range queries order values, or refuse', () => { - let brain: Brainy - let index: any - let ids: string[] - - beforeEach(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - index = (brain as any).metadataIndex - - // Rows exist (so the id mapper can resolve them) but carry NO `legacyTitle` - // — the column store must not serve the field the pins query. - ids = [] - for (let i = 0; i < 3; i++) { - const id = `row-${i}` - await brain.add({ id, data: `row ${i}`, type: NounType.Thing, metadata: { lane: 'a' }, vector: [] }) - ids.push(id) - } - expect(index.columnStore.hasField(FIELD)).toBe(false) - }) - - describe('(a) a long BOUND against ordinary short values', () => { - // 'apple' < 'mango' < 'zebra', and every bound below is compared against - // these three raw keys. - beforeEach(async () => { - await writeLegacySparseIndex(brain, FIELD, [ - ['apple', [ids[0]]], - ['mango', [ids[1]]], - ['zebra', [ids[2]]] - ]) - }) - - it('the fixture: the values are stored raw, the long bound is what hashes', () => { - expect(index.normalizeValue('apple', FIELD)).toBe('apple') - // The bound is what the old code collapsed — and a digest sorts below - // every letter, which is exactly why `gte` matched everything. - const bound = longString('zzz', 120) - expect(index.normalizeValue(bound, FIELD)).toMatch(/^__HASH_/) - expect(index.normalizeValue(bound, FIELD) < 'apple').toBe(true) - }) - - it('gte a bound above every value matches NOTHING (it used to match all)', async () => { - const bound = longString('zzz', 120) - const matched = await index.getIdsForRange(FIELD, bound, undefined, true, true) - expect(matched).toEqual([]) - }) - - it('lte a bound above every value matches EVERY value', async () => { - const bound = longString('zzz', 120) - const matched = await index.getIdsForRange(FIELD, undefined, bound, true, true) - expect(matched).toHaveLength(3) - }) - - it('gte a long bound below every value matches every value', async () => { - const bound = longString('aaa', 120) - const matched = await index.getIdsForRange(FIELD, bound, undefined, true, true) - expect(matched).toHaveLength(3) - }) - - it('a long bound orders BETWEEN the values, not below all of them', async () => { - // 'mmm…' sits between 'mango' and 'zebra'. - const bound = longString('mmm', 120) - const matched = await index.getIdsForRange(FIELD, bound, undefined, true, true) - expect(matched).toHaveLength(1) - }) - - it('short bounds are unchanged — the ordinary case still orders correctly', async () => { - expect(await index.getIdsForRange(FIELD, 'b', undefined, true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, undefined, 'n', true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, 'b', 'n', true, true)).toHaveLength(1) - // Strict bounds stay strict. - expect(await index.getIdsForRange(FIELD, 'mango', undefined, false, true)).toHaveLength(1) - expect(await index.getIdsForRange(FIELD, 'mango', undefined, true, true)).toHaveLength(2) - }) - }) - - describe('(b) long VALUES — the index holds hashes, so the range is refused', () => { - beforeEach(async () => { - await writeLegacySparseIndex(brain, FIELD, [ - [longString('alpha', 140), [ids[0]]], - [longString('mike', 140), [ids[1]]], - [longString('zulu', 140), [ids[2]]] - ]) - }) - - it('the fixture: the persisted keys really are hashes', async () => { - const chunk = await index.chunkManager.loadChunk(FIELD, 0) - const keys = Array.from(chunk.entries.keys()) as string[] - expect(keys).toHaveLength(3) - for (const k of keys) expect(k).toMatch(/^__HASH_/) - // And their digest order is NOT their value order — the wrong answer the - // old code returned was wrong, not merely arbitrary. - const digestOrder = [...keys].sort() - const valueOrder = [ - index.normalizeValue(longString('alpha', 140), FIELD), - index.normalizeValue(longString('mike', 140), FIELD), - index.normalizeValue(longString('zulu', 140), FIELD) - ] - expect(digestOrder).not.toEqual(valueOrder) - }) - - it('a range over the hashed field throws a typed refusal naming the field', async () => { - await expect( - index.getIdsForRange(FIELD, longString('mike', 140), undefined, true, true) - ).rejects.toThrow(BrainyError) - - const err = await index - .getIdsForRange(FIELD, longString('mike', 140), undefined, true, true) - .catch((e: any) => e) - expect(err).toBeInstanceOf(BrainyError) - expect(err.type).toBe('INVALID_QUERY') - expect(err.message).toContain(FIELD) - expect(err.message).toContain('hash') - // The cure is named, not left to the caller to guess. - expect(err.message).toContain('repairIndex') - }) - - it('every range shape refuses — gte, lte and between alike', async () => { - const lo = longString('alpha', 140) - const hi = longString('zulu', 140) - for (const [min, max] of [ - [lo, undefined], - [undefined, hi], - [lo, hi] - ] as Array<[any, any]>) { - const err = await index.getIdsForRange(FIELD, min, max, true, true).catch((e: any) => e) - expect(err).toBeInstanceOf(BrainyError) - expect(err.type).toBe('INVALID_QUERY') - } - }) - - it('EQUALITY still works on the hashed field — only ordering is refused', async () => { - const matched = await index.getIds(FIELD, longString('mike', 140)) - expect(matched).toHaveLength(1) - }) - }) - - describe('numeric ranges on the legacy path are untouched', () => { - beforeEach(async () => { - await writeLegacySparseIndex(brain, FIELD, [ - ['5', [ids[0]]], - ['50', [ids[1]]], - ['500', [ids[2]]] - ]) - }) - - it('numbers still compare numerically, not lexicographically', async () => { - // The whole point of compareNormalizedValues: "50" < "500" numerically - // even though "500" < "50" would hold as strings by prefix. - expect(await index.getIdsForRange(FIELD, 10, undefined, true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, undefined, 100, true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, 10, 100, true, true)).toHaveLength(1) - }) - }) -}) diff --git a/tests/vfs/vfs-search-path-scope.unit.test.ts b/tests/vfs/vfs-search-path-scope.unit.test.ts deleted file mode 100644 index 1f3f5333..00000000 --- a/tests/vfs/vfs-search-path-scope.unit.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @module tests/vfs/vfs-search-path-scope.unit - * @description `vfs.search({ path })` scopes with a SERVED filter. - * - * The scope used to be emitted as `path: { $startsWith }` — an operator that is - * not in the filter vocabulary at all, and whose `$`-less spelling the metadata - * index refuses by the served-operator law (an equality/range posting index - * cannot evaluate a substring without reading every row). Every path-scoped VFS - * search threw; none has ever worked on this engine line. - * - * The scope is now a half-open range over `metadata.path`, which is the VFS's - * truth, is indexed on every VFS entity, and is served by the ordered range - * operators: `[dir + '/', dir + '0')` — '0' being the code point after '/', so - * membership in the range is EXACTLY "carries the prefix `dir/`". The - * non-recursive scope is the directory's own identity, `parent`, an equality. - * - * These pins hold the answer (descendants at every depth, siblings never — the - * `/scope-sibling` trap included), the shape (the operators the search emits - * are answered by the index's own door, never refused), and the law that the - * scope narrows the search BEFORE it runs rather than filtering an over-fetch. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js' -import { Brainy } from '../../src/brainy.js' -import { VFSErrorCode } from '../../src/vfs/types.js' - -/** A word every fixture file carries, so the text leg reaches all of them. */ -const TOKEN = 'quasar' - -describe('vfs.search({ path }) scopes with a served filter', () => { - let brain: Brainy - let vfs: VirtualFileSystem - - /** In scope for '/scope', at three depths. */ - const inScope = ['/scope/a.txt', '/scope/sub/b.txt', '/scope/sub/deep/c.txt'] - /** Out of scope — including the two prefix traps a naive test misses. */ - const outOfScope = ['/scope-sibling/d.txt', '/scope0/e.txt', '/elsewhere/f.txt', '/g.txt'] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) - await brain.init() - vfs = brain.vfs - await vfs.init() - - await vfs.mkdir('/scope/sub/deep', { recursive: true }) - await vfs.mkdir('/scope-sibling', { recursive: true }) - await vfs.mkdir('/scope0', { recursive: true }) - await vfs.mkdir('/elsewhere', { recursive: true }) - - for (const path of [...inScope, ...outOfScope]) { - await vfs.writeFile(path, `${TOKEN} content for ${path}`) - } - }) - - afterAll(async () => { - await vfs?.close() - await brain?.close() - }) - - it('includes every descendant depth and excludes every sibling', async () => { - const results = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - const paths = results.map((r) => r.path).sort() - - expect(paths).toEqual([...inScope].sort()) - for (const path of outOfScope) expect(paths).not.toContain(path) - }) - - it('a trailing slash and a doubled slash name the same scope', async () => { - const plain = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - const trailing = await vfs.search(TOKEN, { path: '/scope/', limit: 50 }) - const doubled = await vfs.search(TOKEN, { path: '//scope//', limit: 50 }) - - const ids = (rs: Array<{ entityId: string }>) => rs.map((r) => r.entityId).sort() - expect(ids(trailing)).toEqual(ids(plain)) - expect(ids(doubled)).toEqual(ids(plain)) - }) - - it('the root scope is every VFS file — it adds no clause to narrow with', async () => { - const rooted = await vfs.search(TOKEN, { path: '/', limit: 50 }) - const unscoped = await vfs.search(TOKEN, { limit: 50 }) - - const paths = rooted.map((r) => r.path).sort() - expect(paths).toEqual([...inScope, ...outOfScope].sort()) - expect(paths).toEqual(unscoped.map((r) => r.path).sort()) - }) - - it('recursive: false is the immediate children, not the subtree', async () => { - const results = await vfs.search(TOKEN, { path: '/scope', recursive: false, limit: 50 }) - expect(results.map((r) => r.path)).toEqual(['/scope/a.txt']) - }) - - it('recursive: false on a path that does not exist refuses by name', async () => { - await expect( - vfs.search(TOKEN, { path: '/no-such-dir', recursive: false, limit: 50 }) - ).rejects.toMatchObject({ code: VFSErrorCode.ENOENT }) - }) - - it('every operator the search emits is ANSWERED by the index door, never refused', async () => { - const index = (brain as any).metadataIndex - const emitted: any[] = [] - const find = vi.spyOn(brain as any, 'find') - try { - await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - await vfs.search(TOKEN, { path: '/scope/sub', where: { mimeType: 'text/plain' }, limit: 50 }) - await vfs.search(TOKEN, { path: '/scope', recursive: false, limit: 50 }) - await vfs.search(TOKEN, { path: '/', limit: 50 }) - for (const call of find.mock.calls) emitted.push((call[0] as any).where) - } finally { - find.mockRestore() - } - - expect(emitted).toHaveLength(4) - for (const where of emitted) { - // The door itself is the judge: an operator outside the served set is - // REFUSED here (BrainyError INVALID_QUERY), never answered. - await expect(index.getIdsForFilter(where)).resolves.toBeInstanceOf(Array) - } - - // And the scope really is a range on the path — the shape this fix chose. - expect(emitted[0].path).toEqual({ gte: '/scope/', lt: '/scope0' }) - expect(emitted[3].path).toBeUndefined() - }) - - it('the scope narrows the search before it runs — no over-fetch to filter', async () => { - const index = (brain as any).metadataIndex - const filter = vi.spyOn(index, 'getIdsForFilter') - let universe: string[] = [] - try { - await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - // The search's own call — the one carrying the scope. (Path resolution - // asks this same door for the root, before the search is built.) - const scoped = filter.mock.calls.findIndex( - (c) => (c[0] as any)?.path?.gte === '/scope/' - ) - expect(scoped).toBeGreaterThanOrEqual(0) - universe = (await filter.mock.results[scoped].value) as string[] - } finally { - filter.mockRestore() - } - - // The id universe the index resolved for the search is already the scope: - // three files, and not one row from outside it. - const rows = await brain.batchGet(universe) - const paths = [...rows.values()].map((e: any) => e.metadata.path).sort() - expect(paths).toEqual([...inScope].sort()) - }) - - it('the range answers the same ids as walking the tree', async () => { - // The path is the truth and the Contains edges are its projection; a scope - // read from the truth must agree with one walked over the projection. - const walked: string[] = [] - const walk = async (dir: string): Promise => { - for (const name of await vfs.readdir(dir)) { - const child = dir === '/' ? `/${name}` : `${dir}/${name}` - const stat = await vfs.stat(child) - if (stat.isDirectory()) await walk(child) - else walked.push(child) - } - } - await walk('/scope') - - const searched = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - expect(searched.map((r) => r.path).sort()).toEqual(walked.sort()) - }) -}) diff --git a/tests/vfs/vfs.unit.test.ts b/tests/vfs/vfs.unit.test.ts index b4024155..4b4ba8d2 100644 --- a/tests/vfs/vfs.unit.test.ts +++ b/tests/vfs/vfs.unit.test.ts @@ -389,14 +389,7 @@ describe('VirtualFileSystem - Production Tests', () => { }) describe('Performance', () => { - it('should handle many files efficiently', async (ctx) => { - // Wall-clock budget assertion — belongs to the perf lane (npm run - // test:perf), not the correctness gate: 121ms alone but 16.5s under - // the gate's sibling-file contention, a flake the code never caused - // (same pattern as storage-batch-operations.test.ts's batch-vs- - // individual timing case). - ctx.skip(!process.env.BRAINY_PERF_LANE, 'wall-clock budget assertion — runs only under the perf lane (npm run test:perf)') - + it('should handle many files efficiently', async () => { const dir = '/performance-test' await vfs.mkdir(dir) diff --git a/vitest.config.ts b/vitest.config.ts index 013c3c9b..116ab234 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,16 +2,9 @@ import { defineConfig } from 'vitest/config' /** * Vitest Configuration - Optimized for Memory-Intensive Tests - * + * * Handles ONNX transformer model testing (4-8GB memory requirement) * Based on 2024-2025 best practices - * - * THE CORRECTNESS GATE: this is the config a bare `vitest run` (no - * `--config` flag) picks up — the delta gate and CI both invoke it that - * way. See CONTRIBUTING.md's "Test gate" section for the full picture. - * Wall-clock/scale benchmarks and tests whose outcome depends on the host - * machine or network rather than the code are excluded below and run on - * demand instead, in their own slot: `npm run test:perf`. */ export default defineConfig({ test: { @@ -45,29 +38,7 @@ export default defineConfig({ 'node_modules/**', 'dist/**', 'scripts/**', - '**/*.browser.test.ts', - - // Wall-clock/scale benchmark family — timing assertions and scale - // sweeps whose pass/fail depends on the host machine's speed, not on - // the code. Whole files only (a file that mixes correctness describes - // with a perf describe stays in the gate). Run on demand via - // `npm run test:perf`, which targets exactly this list. - 'tests/performance/**', - 'tests/critical-performance-benchmark.test.ts', - 'tests/api/performance-benchmarks.test.ts', - - // Environment-dependent by construction, not timing-based: - // package-size-limit shells out to the `npm` CLI (not guaranteed - // present — the functional gate lane is Bun-only host-mode with no - // Node.js runtime) and parses npm-version-specific `npm pack` notice - // text; model-loading's "Real Model Download Integration" case makes - // a genuine, unmocked network call to HuggingFace (its own header - // says "Uses REAL transformer models - NO MOCKING"), and the whole - // file imports `../src/embeddings/model-manager.js`, which no longer - // exists anywhere under src/ — neither belongs in a gate that must be - // deterministic. - 'tests/package-size-limit.test.ts', - 'tests/model-loading.test.ts' + '**/*.browser.test.ts' ], // REPORTERS: Dot for CI, verbose for local