Compare commits
1 commit
main
...
fix/metada
| Author | SHA1 | Date | |
|---|---|---|---|
| a128f0eda5 |
54 changed files with 424 additions and 855 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -2,28 +2,6 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||||
|
|
||||||
|
|
||||||
### [10.4.13](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.12...v10.4.13) (2026-09-03)
|
|
||||||
|
|
||||||
- A shutdown that holds its listener until the exit decision, and a test suite that closes every brain it opens
|
|
||||||
- fix(shutdown): the engine's signal handler keeps its listener registered until the exit decision is made — closing the last live instance no longer deregisters the handler mid-run, so a second signal delivery during a clean shutdown can never kill the process after the work is done (a2ea21b3)
|
|
||||||
- fix(release): the release wall entry commits under an explicit git identity read from the developer's checkout; a host with no identity refuses by name instead of failing inside git (aac853d3)
|
|
||||||
- test(hygiene): every brain a test file creates is closed by that file — 40 files fixed, the leaks that let a stray cadence narrate into later files are gone; brains whose init() was expected to fail are closed too (6eb5e448)
|
|
||||||
|
|
||||||
### [10.4.12](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.11...v10.4.12) (2026-09-03)
|
|
||||||
|
|
||||||
- Mixed-kind fields index exactly, arrays to 256, a drained loop is not a shutdown, and finds project from the column store
|
|
||||||
- fix(index): a metadata field holds every value kind it was written with — one posting column per (field, kind); an equality filter reads the query value's own kind, a range routes by its bounds; nothing is refused and nothing is silently dropped; an index written by the old shape opens unchanged (a128f0ed)
|
|
||||||
- fix(metadata): metadata arrays index up to 256 elements; a longer array refuses at write time by name (MetadataArrayTooLargeError) — a vector parked in metadata now throws; move it to `vector` (e435da78)
|
|
||||||
- fix(shutdown): beforeExit runs a non-closing flush only — a script that never calls close() exits with the writer lock on disk and no clean-shutdown marker, and the next open evicts the stale lock and folds the log, bounded; SIGTERM and SIGINT are unchanged (6baa4d7f)
|
|
||||||
- feat(find): field projection — find({fields}) and get({fields}) resolve scalars from the column store on every leg, including vector-leg finds; absent fields stay absent (ad0f493f)
|
|
||||||
- fix(find): orderBy is the order on every find path, not only the metadata-only one (5e720d17)
|
|
||||||
- fix(metadata): the legacy sparse range path orders values, or refuses by name — never ranks by hash (a7eb7f52)
|
|
||||||
- fix(close): a read-only brain writes nothing under `_system/` (f27a7776)
|
|
||||||
- fix(contract): the flush gate's internals are private, not doors (72c8ee6a)
|
|
||||||
- test(hygiene): the triple-intelligence correctness cases sit in the gate; the idle and connected-find pins name the brain they measure (28083981)
|
|
||||||
- ci(release): the rail writes its own wall entry into the shared releases repo — never hand-written again (adcb883e)
|
|
||||||
|
|
||||||
### [10.4.11](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.9...v10.4.11) (2026-09-02)
|
### [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)
|
- ci: superseded pushes cancel their own runs (concurrency per ref) (6053f6d4)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@
|
||||||
|
|
||||||
<h1 align="center">Brainy</h1>
|
<h1 align="center">Brainy</h1>
|
||||||
|
|
||||||
> **Frozen at 10.4.13 (2026-09-03).** This repository is the reference implementation of the Brainy store format and API,
|
|
||||||
> published under the MIT license. Version 10.4.13 is its last release; the repository is read-only from here. The engine
|
|
||||||
> continues as `@soulcraft/brainy`, which bundles this layer as owned code; every published version of this package stays
|
|
||||||
> available on The Source. Use this repository to read a Brainy store independently or to verify the conformance contract.
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>Three database paradigms. One API. Zero configuration.</b><br>
|
<b>Three database paradigms. One API. Zero configuration.</b><br>
|
||||||
The in-process knowledge database for TypeScript — vector search, graph traversal,<br>
|
The in-process knowledge database for TypeScript — vector search, graph traversal,<br>
|
||||||
|
|
|
||||||
10
RELEASES.md
10
RELEASES.md
|
|
@ -1,15 +1,5 @@
|
||||||
# @soulcraft/brainy — Release Notes for Consumers
|
# @soulcraft/brainy — Release Notes for Consumers
|
||||||
|
|
||||||
> **Frozen at 10.4.13 (2026-09-03).** 10.4.13 is the last release of `@soulcraftlabs/brainy`; this repository is read-only from here.
|
|
||||||
> Release notes for the product engine continue on its own wall.
|
|
||||||
|
|
||||||
Machine-readable release notes are published at
|
|
||||||
https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/open-brainy.json
|
|
||||||
(this engine) and
|
|
||||||
https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/brainy.json
|
|
||||||
(the product engine) — read by HQ's `/hq/releases` door, and the source of
|
|
||||||
truth ahead of this file.
|
|
||||||
|
|
||||||
This file is the **quick reference for downstream sessions** tracking Brainy changes.
|
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
|
Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraftlabs/open-brainy/releases
|
||||||
|
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@soulcraftlabs/brainy",
|
"name": "@soulcraftlabs/brainy",
|
||||||
"version": "10.4.13",
|
"version": "10.4.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@soulcraftlabs/brainy",
|
"name": "@soulcraftlabs/brainy",
|
||||||
"version": "10.4.13",
|
"version": "10.4.11",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.1.2",
|
"@msgpack/msgpack": "^3.1.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@soulcraftlabs/brainy",
|
"name": "@soulcraftlabs/brainy",
|
||||||
"version": "10.4.13",
|
"version": "10.4.11",
|
||||||
"brainyContract": 1,
|
"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.",
|
"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",
|
"main": "dist/index.js",
|
||||||
|
|
|
||||||
76
releases/brainy.json
Normal file
76
releases/brainy.json
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"product": "brainy",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"version": "11.0.5",
|
||||||
|
"date": "2026-09-02",
|
||||||
|
"headline": "Graph-first finds in production, and opens that stop rescanning history",
|
||||||
|
"items": [
|
||||||
|
"find({ connected, where }) now walks the neighbours first and filters only those rows through a native door — correct at every page and O(neighbours), never the whole store.",
|
||||||
|
"related() with a list of verb types returns every requested kind (a fast path had silently kept only the first).",
|
||||||
|
"Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open — measured at two minutes on a large brain, now milliseconds."
|
||||||
|
],
|
||||||
|
"url": null,
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "11.0.4",
|
||||||
|
"date": "2026-09-01",
|
||||||
|
"headline": "Closes in milliseconds, index rebuilds without the disk-sync storm",
|
||||||
|
"items": [
|
||||||
|
"close() no longer pays deferred compaction or waits out an in-flight rebuild — measured 8 ms against the 4-minute closes it replaces; deferred work resumes at the next open, in the background.",
|
||||||
|
"The metadata index's rebuild syncs to disk per shard instead of per row, and the durability point moved to the publish step — the same guarantee, a fraction of the disk traffic.",
|
||||||
|
"A new native filter door evaluates queries over exactly the candidate rows a graph walk found, never the whole store."
|
||||||
|
],
|
||||||
|
"url": null,
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "11.0.3",
|
||||||
|
"date": "2026-09-01",
|
||||||
|
"headline": "The embedding upgrade ceremony runs on every brain",
|
||||||
|
"items": [
|
||||||
|
"A brain opened through the standard plugin now carries its embedding-model identity, so the full-precision upgrade ceremony can run on it.",
|
||||||
|
"A one-fix release; nothing else changed."
|
||||||
|
],
|
||||||
|
"url": null,
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "11.0.2",
|
||||||
|
"date": "2026-08-31",
|
||||||
|
"headline": "One embedding quality everywhere, 3–4× faster imports",
|
||||||
|
"items": [
|
||||||
|
"Every runtime embeds with the same full-precision model — search quality no longer depends on where you run.",
|
||||||
|
"Bulk embedding measured 3.1–4.2× faster, and an online re-embed ceremony upgrades existing stores without downtime.",
|
||||||
|
"The engine's change feed is documented, with the SSE/WebSocket fan-out pattern for realtime surfaces."
|
||||||
|
],
|
||||||
|
"url": null,
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "11.0.1",
|
||||||
|
"date": "2026-08-31",
|
||||||
|
"headline": "Deletes inside transactions are safe",
|
||||||
|
"items": [
|
||||||
|
"Deleting relations inside a transact() no longer corrupts index bookkeeping.",
|
||||||
|
"A store that deletes its last relation keeps serving instead of refusing."
|
||||||
|
],
|
||||||
|
"url": null,
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "11.0.0",
|
||||||
|
"date": "2026-08-28",
|
||||||
|
"headline": "One install, one engine — Brainy",
|
||||||
|
"items": [
|
||||||
|
"The former two-package pair is one package: the native engine under the familiar API. One import is the whole install.",
|
||||||
|
"A missing native build refuses loudly with its cures named; nothing falls back silently.",
|
||||||
|
"Stores open in place — no migration."
|
||||||
|
],
|
||||||
|
"url": null,
|
||||||
|
"thumb": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"history": "The version line continues from the 4.3.x native-engine releases; their record lives in the product repository's CHANGELOG.md."
|
||||||
|
}
|
||||||
136
releases/open-brainy.json
Normal file
136
releases/open-brainy.json
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
{
|
||||||
|
"product": "open-brainy",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"version": "10.4.11",
|
||||||
|
"date": "2026-09-02",
|
||||||
|
"headline": "Hybrid finds filter before they hydrate, one owner per shutdown, and a faster open",
|
||||||
|
"items": [
|
||||||
|
"Hybrid finds (query/vector combined with a filter, including connected and fusion finds) now filter first and hydrate only the page — one batchGet of exactly the requested rows, instead of hydrating everything the search side found. Fixes a bug where any page after the first came back empty.",
|
||||||
|
"A brain now has exactly one shutdown owner — a host and its engine no longer race to close the same store, and a follow-up flush requested during a running flush is handed off cleanly instead of ever risking a stall.",
|
||||||
|
"find({ path }) and other path-scoped VFS searches now serve a real range over the indexed path (O(log n)) instead of refusing the query outright — both scoped and recursive:false searches were silently broken before this.",
|
||||||
|
"Open no longer rescans a brain's whole fact log on every open — sealed segments the manifest already accounts for are skipped, collapsing a multi-second open term to near-zero on large brains.",
|
||||||
|
"commitTransaction() now refuses by name if single-ops are still pending, and a read-only open no longer writes clean-shutdown evidence it didn't earn — two correctness invariants that were previously assumed, not enforced."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.10",
|
||||||
|
"date": "2026-09-02",
|
||||||
|
"headline": "A planner door for indexes, batched containment repair, and a fixed near()",
|
||||||
|
"items": [
|
||||||
|
"An optional planFindPage door lets an index plan a find() and answer it in one call, instead of the engine assembling the plan itself.",
|
||||||
|
"repairContainment's reconcile pass now walks paged edges once instead of issuing one graph call per file.",
|
||||||
|
"find({ near }) now searches around the anchor's own vector and refuses by name when none is available, instead of silently querying with no vector at all."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.10",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.9",
|
||||||
|
"date": "2026-09-02",
|
||||||
|
"headline": "Graph-first finds, honest verb arrays, and opens that stop rescanning history",
|
||||||
|
"items": [
|
||||||
|
"find({ connected, where }) now walks the neighbours first and filters only those rows — correct at every page, and O(neighbours) instead of O(store).",
|
||||||
|
"related() with a list of verb types (or sources, or targets) returns every requested kind — four fast paths silently kept only the first.",
|
||||||
|
"Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open — measured at two minutes on a large brain, now milliseconds."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.9",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.7",
|
||||||
|
"date": "2026-09-01",
|
||||||
|
"headline": "Count ledgers can no longer race themselves",
|
||||||
|
"items": [
|
||||||
|
"Concurrent count flushes coalesce into one writer with a trailing pass — parallel flushes can no longer corrupt a store's count ledger.",
|
||||||
|
"Atomic writes carry a per-process sequence, so two processes' temp files can never collide."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.7",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.6",
|
||||||
|
"date": "2026-08-31",
|
||||||
|
"headline": "Transactions cross the index seam safely",
|
||||||
|
"items": [
|
||||||
|
"Deleting relations inside a transact() no longer fails against the metadata index — operations take a JSON-safe view at the moment they execute.",
|
||||||
|
"Fixes a class of transaction failures on stores with integer-mapped relation endpoints."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.6",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.5",
|
||||||
|
"date": "2026-08-31",
|
||||||
|
"headline": "Recovery tells the truth, docs live at home",
|
||||||
|
"items": [
|
||||||
|
"A torn generation-log tail is a terminal verdict with a named cure — never an endless wait at open.",
|
||||||
|
"A sealed segment declares only the generations it actually holds.",
|
||||||
|
"The engine's documentation now publishes from its own repository."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.5",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.4",
|
||||||
|
"date": "2026-08-28",
|
||||||
|
"headline": "Faster opens, quieter idle",
|
||||||
|
"items": [
|
||||||
|
"Opening a store discovers generations from directory names instead of walking the log, and answers \"any entities?\" with one directory read.",
|
||||||
|
"The flush-request watch is event-driven; idle stores stop paying a polling heartbeat.",
|
||||||
|
"A slow open now names the exact step it is in, so operators see what is being paid and why."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.4",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.3",
|
||||||
|
"date": "2026-08-27",
|
||||||
|
"headline": "Open Brainy, under its own name",
|
||||||
|
"items": [
|
||||||
|
"The same engine as 10.4.2, now published as @soulcraftlabs/brainy — the MIT reference engine, on The Source.",
|
||||||
|
"No code changes; your imports change once and everything else stays put."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.3",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.2",
|
||||||
|
"date": "2026-08-27",
|
||||||
|
"headline": "Vectors that lie are refused, counts that drift are caught",
|
||||||
|
"items": [
|
||||||
|
"A zero-norm vector is not a vector: the index refuses them, rebuilds skip them, and a sanctioned unvector door removes them cleanly.",
|
||||||
|
"The canonical count ledger derives from identity records and marks legacy-derived ledgers suspect at load.",
|
||||||
|
"Plugin activation failures keep their original error as cause, so the real frame reaches your logs."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.2",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.1",
|
||||||
|
"date": "2026-08-26",
|
||||||
|
"headline": "Writes that change nothing cost nothing",
|
||||||
|
"items": [
|
||||||
|
"The read gate is per index family, and a write carrying unchanged data never re-embeds.",
|
||||||
|
"The vectored-row count joins the ledger, so vector coverage is a number you can read, not a guess."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.1",
|
||||||
|
"thumb": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "10.4.0",
|
||||||
|
"date": "2026-08-26",
|
||||||
|
"headline": "Repair routing, the vector ledger, and honest empties",
|
||||||
|
"items": [
|
||||||
|
"Repairs route to the index that owns the damage, and the open gate closes the vector leg until coverage is proven.",
|
||||||
|
"An empty string is real data, not a missing field.",
|
||||||
|
"The metadata crossing never carries raw integer relation endpoints — a whole class of serialization faults closed."
|
||||||
|
],
|
||||||
|
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.0",
|
||||||
|
"thumb": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"history": "Earlier releases are recorded in CHANGELOG.md in this repository."
|
||||||
|
}
|
||||||
|
|
@ -159,21 +159,9 @@ CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs
|
||||||
|
|
||||||
${COMMITS}
|
${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 <last-tag>..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
|
# Prepend to CHANGELOG.md after header
|
||||||
if [ "$CURATED_ENTRY" = false ] && [ -f "CHANGELOG.md" ]; then
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
# Read header (first 4 lines)
|
# Read header (first 4 lines)
|
||||||
HEADER=$(head -n 4 CHANGELOG.md)
|
HEADER=$(head -n 4 CHANGELOG.md)
|
||||||
# Read rest of file
|
# Read rest of file
|
||||||
|
|
@ -188,21 +176,16 @@ fi
|
||||||
echo -e "${GREEN}✅ CHANGELOG updated${NC}\n"
|
echo -e "${GREEN}✅ CHANGELOG updated${NC}\n"
|
||||||
|
|
||||||
# Step 6b: Update the releases wall entry — mechanical, derived from the
|
# Step 6b: Update the releases wall entry — mechanical, derived from the
|
||||||
# CHANGELOG entry just composed. The fleet's HQ page reads open-brainy.json
|
# CHANGELOG entry just composed. The fleet's HQ page reads releases/open-brainy.json
|
||||||
# from the one shared releases repo, soulcraftlabs/releases on The Source —
|
# directly; this used to be hand-written after every release (David: never
|
||||||
# this used to be hand-written after every release (David: never again —
|
# again — make it a step of the rail).
|
||||||
# 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}"
|
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
|
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"
|
echo -e "${GREEN}✅ Releases wall updated${NC}\n"
|
||||||
|
|
||||||
# Step 7: Create release commit
|
# Step 7: Create release commit
|
||||||
echo -e "${BLUE}6️⃣ Creating release commit...${NC}"
|
echo -e "${BLUE}6️⃣ Creating release commit...${NC}"
|
||||||
git add package.json package-lock.json CHANGELOG.md
|
git add package.json package-lock.json CHANGELOG.md releases/open-brainy.json
|
||||||
git commit -m "chore(release): ${NEW_VERSION}"
|
git commit -m "chore(release): ${NEW_VERSION}"
|
||||||
echo -e "${GREEN}✅ Release commit created${NC}\n"
|
echo -e "${GREEN}✅ Release commit created${NC}\n"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,85 +2,48 @@
|
||||||
/**
|
/**
|
||||||
* @module scripts/wall-entry
|
* @module scripts/wall-entry
|
||||||
* @description The releases-wall entry, made mechanical. The fleet's HQ page
|
* @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
|
* reads one public JSON per product (releases/<product>.json — shape
|
||||||
* (soulcraftlabs/releases, files <product>.json at its root — shape
|
* {product, entries:[{version, date, headline, items, url, thumb}], history}).
|
||||||
* {product, entries:[{version, date, headline, items, url, thumb?}]}), at
|
* Those entries were hand-written after every release; this script is the
|
||||||
* https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/<product>.json.
|
* one door that composes one, so it never has to be typed by hand again.
|
||||||
* Those entries were hand-written after every release, then briefly written
|
|
||||||
* into this repo's own releases/<product>.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:
|
* Two modes:
|
||||||
*
|
*
|
||||||
* 1. Generate + publish (default):
|
* 1. Generate + write in place (default):
|
||||||
* node wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> \
|
* node wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> \
|
||||||
* --from-changelog <CHANGELOG.md>
|
* --from-changelog <CHANGELOG.md> [--file releases/<p>.json]
|
||||||
* Derives an entry from the CHANGELOG.md entry for <v> (headline = the
|
* Derives an entry from the CHANGELOG.md entry for <v> (headline = the
|
||||||
* entry's first bullet, items = every bullet, trimmed of its trailing
|
* entry's first bullet, items = every bullet, trimmed of its trailing
|
||||||
* commit hash), then:
|
* commit hash), prepends it to --file (default releases/<product>.json,
|
||||||
* - clones (or, if a cached clone already exists, fetches and resets)
|
* newest first), refusing by name if <v> is already present, and
|
||||||
* the releases repo into a local cache directory,
|
* validates the whole file's shape + ordering before and after writing.
|
||||||
* - prepends the entry to <cache>/<p>.json, newest first — replacing
|
* Both engines run this identically, each against its own repo's
|
||||||
* any existing entry for the same version so a re-run is idempotent,
|
* releases/<product>.json — the wall file always lives beside the
|
||||||
* - validates the file's shape before and after,
|
* CHANGELOG it is derived from, never in another repo.
|
||||||
* - commits the change as "chore(wall): <p> <v>" 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:
|
* 2. Validate only (--check):
|
||||||
* node wall-entry.mjs --dry-run --product <p> --version <v> \
|
* node wall-entry.mjs --check --file <releases/p.json>
|
||||||
* --date <YYYY-MM-DD> --from-changelog <CHANGELOG.md>
|
* Validates the file's exact key set (top-level and per-entry), field
|
||||||
* Derives the entry exactly as above and prints it, along with the file
|
* types, and strict-descending semver ordering with no duplicates.
|
||||||
* it would be written to, but touches no clone and no remote — usable
|
* Read-only; never writes. Exit 0 = clean, exit 1 = named violations
|
||||||
* from a fresh checkout with no cache and no network.
|
* printed to stderr.
|
||||||
*
|
*
|
||||||
* 3. Validate only (--check):
|
* No dependencies — CHANGELOG parsing, semver comparison, and JSON shape
|
||||||
* node wall-entry.mjs --check --file <path/to/product.json>
|
* checking are all hand-rolled below.
|
||||||
* 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 { readFileSync, writeFileSync, existsSync } 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'
|
const ENTRY_KEYS = ['version', 'date', 'headline', 'items', 'url', 'thumb']
|
||||||
|
const FILE_KEYS = ['product', 'entries', 'history']
|
||||||
|
|
||||||
/** @returns {string} */
|
// The public release-page URL pattern, by product — only products with a
|
||||||
function defaultCacheDir() {
|
// PUBLIC forge repo get a derived link. A product without an entry here
|
||||||
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache')
|
// (e.g. "brainy", whose repo is private) gets url: null, matching every
|
||||||
return join(base, 'soulcraft-releases')
|
// entry the fleet has shipped for it so far — a private link would 404 for
|
||||||
}
|
// anyone reading the public HQ page.
|
||||||
|
|
||||||
// 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 = {
|
const RELEASE_URL_PATTERNS = {
|
||||||
'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`,
|
'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}`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -147,11 +110,10 @@ function compareSemver(a, b) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a wall file's full shape: top-level keys ("product", "entries" —
|
* Validate a wall file's full shape: top-level keys, per-entry keys and
|
||||||
* no more, no less), per-entry keys and field types ("thumb" optional), and
|
* field types, and strict-descending semver ordering with no duplicates.
|
||||||
* strict-descending semver ordering with no duplicates. Collects every
|
* Collects every violation instead of failing on the first, so --check
|
||||||
* violation instead of failing on the first, so a caller reports the whole
|
* reports the whole picture in one pass.
|
||||||
* picture in one pass.
|
|
||||||
* @param {unknown} data
|
* @param {unknown} data
|
||||||
* @returns {string[]} Violation messages; empty means the file is clean.
|
* @returns {string[]} Violation messages; empty means the file is clean.
|
||||||
*/
|
*/
|
||||||
|
|
@ -173,6 +135,9 @@ function validateShape(data) {
|
||||||
if (typeof obj.product !== 'string' || obj.product.trim() === '') {
|
if (typeof obj.product !== 'string' || obj.product.trim() === '') {
|
||||||
errors.push('top level: "product" must be a non-empty string')
|
errors.push('top level: "product" must be a non-empty string')
|
||||||
}
|
}
|
||||||
|
if (typeof obj.history !== 'string' || obj.history.trim() === '') {
|
||||||
|
errors.push('top level: "history" must be a non-empty string')
|
||||||
|
}
|
||||||
if (!Array.isArray(obj.entries)) {
|
if (!Array.isArray(obj.entries)) {
|
||||||
errors.push('top level: "entries" must be an array')
|
errors.push('top level: "entries" must be an array')
|
||||||
return errors // nothing further to check without an array
|
return errors // nothing further to check without an array
|
||||||
|
|
@ -187,8 +152,8 @@ function validateShape(data) {
|
||||||
}
|
}
|
||||||
const entry = /** @type {Record<string, unknown>} */ (rawEntry)
|
const entry = /** @type {Record<string, unknown>} */ (rawEntry)
|
||||||
const keys = Object.keys(entry)
|
const keys = Object.keys(entry)
|
||||||
const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry))
|
const missing = ENTRY_KEYS.filter((k) => !(k in entry))
|
||||||
const extra = keys.filter((k) => !ENTRY_ALLOWED_KEYS.includes(k))
|
const extra = keys.filter((k) => !ENTRY_KEYS.includes(k))
|
||||||
if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`)
|
if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`)
|
||||||
if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`)
|
if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`)
|
||||||
|
|
||||||
|
|
@ -204,11 +169,11 @@ function validateShape(data) {
|
||||||
if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) {
|
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`)
|
errors.push(`${label}: "items" must be a non-empty array of non-empty strings`)
|
||||||
}
|
}
|
||||||
if (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) {
|
if (!(entry.url === null || typeof entry.url === 'string')) {
|
||||||
errors.push(`${label}: "url" must be an https permalink — never null; HQ's parser rejects the whole feed`)
|
errors.push(`${label}: "url" must be a string or null`)
|
||||||
}
|
}
|
||||||
if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) {
|
if (!(entry.thumb === null || typeof entry.thumb === 'string')) {
|
||||||
errors.push(`${label}: "thumb" must be a string or null when present`)
|
errors.push(`${label}: "thumb" must be a string or null`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -275,8 +240,8 @@ function extractChangelogBullets(changelog, version) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive a wall entry from a CHANGELOG.md.
|
* Derive a wall entry from a CHANGELOG.md.
|
||||||
* @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts
|
* @param {{product: string, version: string, date: string, changelogPath: string, url?: string | null, thumb?: string | null}} opts
|
||||||
* @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}}
|
* @returns {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}}
|
||||||
*/
|
*/
|
||||||
function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
||||||
if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`)
|
if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`)
|
||||||
|
|
@ -289,11 +254,7 @@ function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
||||||
const items = extractChangelogBullets(changelog, version)
|
const items = extractChangelogBullets(changelog, version)
|
||||||
const headline = items[0]
|
const headline = items[0]
|
||||||
|
|
||||||
const pattern = RELEASE_URL_PATTERNS[product]
|
const resolvedUrl = url !== undefined ? url : (RELEASE_URL_PATTERNS[product]?.(version) ?? null)
|
||||||
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
|
const resolvedThumb = thumb !== undefined ? thumb : null
|
||||||
|
|
||||||
return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb }
|
return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb }
|
||||||
|
|
@ -305,140 +266,43 @@ function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
||||||
* @returns {Record<string, any>}
|
* @returns {Record<string, any>}
|
||||||
*/
|
*/
|
||||||
function loadWallFile(filePath) {
|
function loadWallFile(filePath) {
|
||||||
if (!existsSync(filePath)) fail(`"${filePath}" does not exist`)
|
if (!existsSync(filePath)) fail(`--file "${filePath}" does not exist`)
|
||||||
/** @type {unknown} */
|
/** @type {unknown} */
|
||||||
let data
|
let data
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(`"${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`)
|
fail(`--file "${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`)
|
||||||
}
|
}
|
||||||
const errors = validateShape(data)
|
const errors = validateShape(data)
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
fail(`"${filePath}" fails shape validation —\n ${errors.join('\n ')}`)
|
fail(`--file "${filePath}" fails shape validation before any write —\n ${errors.join('\n ')}`)
|
||||||
}
|
}
|
||||||
return /** @type {Record<string, any>} */ (data)
|
return /** @type {Record<string, any>} */ (data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a git command, throwing an Error whose message is git's own stderr
|
* Prepend `entry` to the wall file at `filePath`, refusing by name if the
|
||||||
* (trimmed) on failure — every caller wraps this to name the cure.
|
* version is already present, validating before and after, and writing the
|
||||||
* @param {string[]} args
|
* file back with the repo's exact formatting (2-space JSON, trailing newline).
|
||||||
* @param {string} cwd
|
* @param {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} entry
|
||||||
* @returns {string} stdout, trimmed.
|
* @param {string} filePath
|
||||||
|
* @param {string | undefined} expectedProduct
|
||||||
*/
|
*/
|
||||||
function git(args, cwd) {
|
function applyEntry(entry, filePath, expectedProduct) {
|
||||||
try {
|
|
||||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
||||||
} catch (err) {
|
|
||||||
const stderr = /** @type {any} */ (err).stderr
|
|
||||||
const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the git identity for the wall commit from the repository the rail
|
|
||||||
* is actually running in — the developer's own checkout (`process.cwd()`;
|
|
||||||
* `release.sh` invokes this script from the repo root with no `cd`), via
|
|
||||||
* git's normal config precedence (repo-local, then global, then system).
|
|
||||||
* Never guessed and never left to git's own "who are you?" prompt: a host
|
|
||||||
* with no configured identity anywhere (a bare CI box, say) must refuse
|
|
||||||
* loudly rather than have git manufacture a placeholder identity or hang.
|
|
||||||
* @returns {{name: string, email: string}}
|
|
||||||
*/
|
|
||||||
function resolveWallCommitIdentity() {
|
|
||||||
const repo = process.cwd()
|
|
||||||
let name = ''
|
|
||||||
let email = ''
|
|
||||||
try {
|
|
||||||
name = git(['config', 'user.name'], repo)
|
|
||||||
} catch {
|
|
||||||
name = ''
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
email = git(['config', 'user.email'], repo)
|
|
||||||
} catch {
|
|
||||||
email = ''
|
|
||||||
}
|
|
||||||
if (!name || !email) {
|
|
||||||
fail('no git identity for the wall commit — set user.name/user.email')
|
|
||||||
}
|
|
||||||
return { name, email }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a clean, up-to-date local clone of the releases repo at
|
|
||||||
* `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no
|
|
||||||
* `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a
|
|
||||||
* stray local commit or edit left by a previous failed run can never leak
|
|
||||||
* into the next one).
|
|
||||||
* @param {string} remote
|
|
||||||
* @param {string} cacheDir
|
|
||||||
*/
|
|
||||||
function ensureReleasesClone(remote, cacheDir) {
|
|
||||||
if (existsSync(join(cacheDir, '.git'))) {
|
|
||||||
try {
|
|
||||||
git(['remote', 'set-url', 'origin', remote], cacheDir)
|
|
||||||
git(['fetch', '--prune', 'origin'], cacheDir)
|
|
||||||
git(['checkout', 'main'], cacheDir)
|
|
||||||
git(['reset', '--hard', 'origin/main'], cacheDir)
|
|
||||||
git(['clean', '-fd'], cacheDir)
|
|
||||||
} catch (err) {
|
|
||||||
fail(
|
|
||||||
`cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" — ${/** @type {Error} */ (err).message}\n` +
|
|
||||||
` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(cacheDir), { recursive: true })
|
|
||||||
try {
|
|
||||||
git(['clone', remote, cacheDir], dirname(cacheDir))
|
|
||||||
} catch (err) {
|
|
||||||
fail(
|
|
||||||
`cannot clone "${remote}" — ${/** @type {Error} */ (err).message}\n` +
|
|
||||||
` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
git(['checkout', 'main'], cacheDir)
|
|
||||||
} catch (err) {
|
|
||||||
fail(
|
|
||||||
`cloned "${remote}" into "${cacheDir}" but could not check out "main" — ${/** @type {Error} */ (err).message}\n` +
|
|
||||||
` cure: confirm the releases repo's default branch is named "main"`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepend `entry` to the wall at `<cacheDir>/<product>.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)
|
const wall = loadWallFile(filePath)
|
||||||
|
|
||||||
if (wall.product !== product) {
|
if (expectedProduct && wall.product !== expectedProduct) {
|
||||||
fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given — refusing a cross-product write`)
|
fail(
|
||||||
|
`--file "${filePath}" has product "${wall.product}", but --product "${expectedProduct}" was given — refusing a cross-product write`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const replacing = wall.entries.some((e) => e.version === entry.version)
|
if (wall.entries.some((e) => e.version === entry.version)) {
|
||||||
wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)]
|
fail(`refusing — version ${entry.version} is already present in "${filePath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
wall.entries = [entry, ...wall.entries]
|
||||||
|
|
||||||
const postErrors = validateShape(wall)
|
const postErrors = validateShape(wall)
|
||||||
if (postErrors.length) {
|
if (postErrors.length) {
|
||||||
|
|
@ -446,53 +310,22 @@ function publishEntry(entry, product, remote, cacheDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8')
|
writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8')
|
||||||
|
console.log(`wall-entry: wrote v${entry.version} to "${filePath}" (${wall.entries.length} entries, newest first)`)
|
||||||
const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir)
|
|
||||||
if (status === '') {
|
|
||||||
console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} — nothing to commit or push`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const identity = resolveWallCommitIdentity()
|
|
||||||
|
|
||||||
try {
|
|
||||||
git(['add', `${product}.json`], cacheDir)
|
|
||||||
git(
|
|
||||||
['-c', `user.name=${identity.name}`, '-c', `user.email=${identity.email}`, 'commit', '-m', `chore(wall): ${product} ${entry.version}`],
|
|
||||||
cacheDir,
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
git(['push', 'origin', 'main'], cacheDir)
|
|
||||||
} catch (err) {
|
|
||||||
fail(
|
|
||||||
`push to "${remote}" failed (likely a non-fast-forward — another release landed on main first) — ${/** @type {Error} */ (err).message}\n` +
|
|
||||||
` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha = git(['rev-parse', 'HEAD'], cacheDir)
|
|
||||||
console.log(
|
|
||||||
`wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) — pushed ${sha} to ${remote} main`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const args = parseArgs(process.argv.slice(2))
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
if (args.check) {
|
if (args.check) {
|
||||||
const filePath = /** @type {string | undefined} */ (args.file)
|
const filePath = /** @type {string | undefined} */ (args.file) ??
|
||||||
if (!filePath) fail('--check needs --file <path>')
|
(typeof args.product === 'string' ? `releases/${args.product}.json` : undefined)
|
||||||
|
if (!filePath) fail('--check needs --file <path> (or --product <name> to default to releases/<name>.json)')
|
||||||
const wall = loadWallFile(/** @type {string} */ (filePath))
|
const wall = loadWallFile(/** @type {string} */ (filePath))
|
||||||
console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`)
|
console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate mode (default, also covers --dry-run): --product, --version,
|
// Generate mode (default): --product, --version, --date, --from-changelog required.
|
||||||
// --date, --from-changelog required.
|
|
||||||
const product = /** @type {string | undefined} */ (args.product)
|
const product = /** @type {string | undefined} */ (args.product)
|
||||||
const version = /** @type {string | undefined} */ (args.version)
|
const version = /** @type {string | undefined} */ (args.version)
|
||||||
const date = /** @type {string | undefined} */ (args.date)
|
const date = /** @type {string | undefined} */ (args.date)
|
||||||
|
|
@ -507,8 +340,8 @@ function main() {
|
||||||
fail(
|
fail(
|
||||||
`missing required flag(s): ${missing.join(', ')}\n` +
|
`missing required flag(s): ${missing.join(', ')}\n` +
|
||||||
'Usage:\n' +
|
'Usage:\n' +
|
||||||
' wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> --from-changelog <CHANGELOG.md> [--dry-run]\n' +
|
' wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> --from-changelog <CHANGELOG.md> [--file releases/<p>.json]\n' +
|
||||||
' wall-entry.mjs --check --file <path/to/product.json>',
|
' wall-entry.mjs --check --file <releases/p.json>',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,16 +357,8 @@ function main() {
|
||||||
thumb: thumbArg,
|
thumb: thumbArg,
|
||||||
})
|
})
|
||||||
|
|
||||||
const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE)
|
const filePath = /** @type {string} */ (args.file ?? `releases/${product}.json`)
|
||||||
const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir())
|
applyEntry(entry, filePath, /** @type {string} */ (product))
|
||||||
|
|
||||||
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()
|
main()
|
||||||
|
|
|
||||||
134
src/brainy.ts
134
src/brainy.ts
|
|
@ -544,23 +544,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
* and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */
|
* and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */
|
||||||
private static beforeExitNarrated = false
|
private static beforeExitNarrated = false
|
||||||
|
|
||||||
/** True for the entire duration of ONE `closeOnShutdown()` run (the
|
|
||||||
* signal-path handler in {@link registerShutdownHooks}) — from before it
|
|
||||||
* starts closing instances until after it has decided whether to exit.
|
|
||||||
* THE RACE THIS CLOSES: closing the LAST live instance calls
|
|
||||||
* `close()` → `deregisterShutdownHooksIfIdle()` synchronously, which
|
|
||||||
* removes `Brainy.sigtermListener` from `process` — while `closeOnShutdown`
|
|
||||||
* (that very listener's OWN still-running invocation) hasn't yet reached
|
|
||||||
* `exitIfSoleShutdownOwner()`'s `process.exit(0)`. In that window Node has
|
|
||||||
* NO registered SIGTERM listener, so a second/concurrent delivery of the
|
|
||||||
* same signal (a raced re-send, common on a loaded host) falls through to
|
|
||||||
* Node's default disposition and kills the process outright — the
|
|
||||||
* clean-shutdown work already finished, but the process never reports the
|
|
||||||
* 0 it earned. `deregisterShutdownHooksIfIdle()` checks this flag and
|
|
||||||
* defers; `closeOnShutdown()`'s `finally` re-runs the deregistration check
|
|
||||||
* once it is done, so the listener never actually leaks past its use. */
|
|
||||||
private static shutdownSignalHandlerActive = false
|
|
||||||
|
|
||||||
/** Poll cadence (ms) for the migration LOCK when a provider exposes no
|
/** Poll cadence (ms) for the migration LOCK when a provider exposes no
|
||||||
* event-driven `whenMigrationComplete()` signal. See {@link awaitMigrationLock}. */
|
* event-driven `whenMigrationComplete()` signal. See {@link awaitMigrationLock}. */
|
||||||
private static readonly MIGRATION_POLL_INTERVAL_MS = 250
|
private static readonly MIGRATION_POLL_INTERVAL_MS = 250
|
||||||
|
|
@ -2213,74 +2196,51 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
*/
|
*/
|
||||||
const closeOnShutdown = async () => {
|
const closeOnShutdown = async () => {
|
||||||
console.log('Shutdown signal received - flushing pending data...')
|
console.log('Shutdown signal received - flushing pending data...')
|
||||||
// HOLD THE LISTENER FOR THE WHOLE RUN. Closing the LAST live instance
|
// DEFER ONE MACROTASK. A host application registers its own listener on
|
||||||
// below calls close() → deregisterShutdownHooksIfIdle(), which removes
|
// the same signal, and Node runs listeners in registration order — ours
|
||||||
// Brainy's own SIGTERM/SIGINT listeners from `process` — synchronously,
|
// is usually first, because the brain was opened before the host wired
|
||||||
// before THIS invocation has reached exitIfSoleShutdownOwner()'s
|
// its shutdown. Yielding once lets every other listener for this signal
|
||||||
// process.exit(0). Left alone, that opens a window with no registered
|
// run its synchronous prologue, so a host that calls close() gets to be
|
||||||
// listener for the signal at all, so a second/concurrent delivery of
|
// the owner. It is only a courtesy, never the safety: close()'s own
|
||||||
// the same signal (a raced re-send — not rare on a loaded host) falls
|
// single-flight gate is what makes a lost race harmless.
|
||||||
// through to Node's default disposition and kills the process outright
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
// AFTER the clean-shutdown work already finished, reporting a signal
|
|
||||||
// kill instead of the 0 the shutdown earned. Setting this flag makes
|
|
||||||
// deregisterShutdownHooksIfIdle() defer; the `finally` below re-checks
|
|
||||||
// it once this run is fully done — closeOnShutdown, not a nested
|
|
||||||
// close(), owns exactly when the listener actually comes off.
|
|
||||||
Brainy.shutdownSignalHandlerActive = true
|
|
||||||
try {
|
|
||||||
// DEFER ONE MACROTASK. A host application registers its own listener
|
|
||||||
// on the same signal, and Node runs listeners in registration order —
|
|
||||||
// ours is usually first, because the brain was opened before the
|
|
||||||
// host wired its shutdown. Yielding once lets every other listener
|
|
||||||
// for this signal run its synchronous prologue, so a host that calls
|
|
||||||
// close() gets to be the owner. It is only a courtesy, never the
|
|
||||||
// safety: close()'s own single-flight gate is what makes a lost race
|
|
||||||
// harmless.
|
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
||||||
|
|
||||||
let closedCount = 0
|
let closedCount = 0
|
||||||
let deferredCount = 0
|
let deferredCount = 0
|
||||||
let failedCount = 0
|
let failedCount = 0
|
||||||
// Snapshot: close() splices Brainy.instances while we iterate.
|
// Snapshot: close() splices Brainy.instances while we iterate.
|
||||||
for (const instance of [...Brainy.instances]) {
|
for (const instance of [...Brainy.instances]) {
|
||||||
if (!instance.initialized) continue
|
if (!instance.initialized) continue
|
||||||
// SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a
|
// SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a
|
||||||
// component close — nothing. Touching a brain whose close is running
|
// component close — nothing. Touching a brain whose close is running
|
||||||
// is the whole defect this handler was rewritten for.
|
// is the whole defect this handler was rewritten for.
|
||||||
if (instance.closed || instance._closeInFlight !== null) {
|
if (instance.closed || instance._closeInFlight !== null) {
|
||||||
deferredCount++
|
deferredCount++
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Law 1: this try/catch is the isolation — the loop continues.
|
|
||||||
await instance.close()
|
|
||||||
closedCount++
|
|
||||||
} catch (error) {
|
|
||||||
failedCount++
|
|
||||||
console.error('Failed to close one Brainy instance on shutdown:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (closedCount > 0) {
|
try {
|
||||||
console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
|
// Law 1: this try/catch is the isolation — the loop continues.
|
||||||
|
await instance.close()
|
||||||
|
closedCount++
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++
|
||||||
|
console.error('Failed to close one Brainy instance on shutdown:', error)
|
||||||
}
|
}
|
||||||
if (deferredCount > 0) {
|
}
|
||||||
console.log(
|
if (closedCount > 0) {
|
||||||
`${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
|
console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
|
||||||
`closing — left to the caller that owns that close.`
|
}
|
||||||
)
|
if (deferredCount > 0) {
|
||||||
}
|
console.log(
|
||||||
if (failedCount > 0) {
|
`${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
|
||||||
console.error(
|
`closing — left to the caller that owns that close.`
|
||||||
`${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
|
)
|
||||||
`their writer locks were released, but their next open will run crash recovery.`
|
}
|
||||||
)
|
if (failedCount > 0) {
|
||||||
}
|
console.error(
|
||||||
} finally {
|
`${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
|
||||||
// Release the hold and run the deferred check ourselves — the last
|
`their writer locks were released, but their next open will run crash recovery.`
|
||||||
// close() above may have found the flag set and skipped its own
|
)
|
||||||
// deregistration, so nobody else will do this if we don't.
|
|
||||||
Brainy.shutdownSignalHandlerActive = false
|
|
||||||
Brainy.deregisterShutdownHooksIfIdle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2444,17 +2404,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
* script that closed every brain exits on its own — a library must never
|
* script that closed every brain exits on its own — a library must never
|
||||||
* keep its host process alive. Re-initializing later re-registers them
|
* keep its host process alive. Re-initializing later re-registers them
|
||||||
* (the `shutdownHooksRegisteredGlobally` flag resets here).
|
* (the `shutdownHooksRegisteredGlobally` flag resets here).
|
||||||
*
|
|
||||||
* Deferred (not skipped — {@link closeOnShutdown}'s `finally` always
|
|
||||||
* re-checks) while a signal-path shutdown is actively running: that
|
|
||||||
* handler's OWN still-in-flight invocation is `Brainy.sigtermListener`, and
|
|
||||||
* removing it out from under itself — which closing the LAST instance here
|
|
||||||
* would otherwise do, synchronously, mid-run — would leave `process` with
|
|
||||||
* no listener for the signal for the remainder of that run. See
|
|
||||||
* {@link shutdownSignalHandlerActive}'s doc for the exact race this closes.
|
|
||||||
*/
|
*/
|
||||||
private static deregisterShutdownHooksIfIdle(): void {
|
private static deregisterShutdownHooksIfIdle(): void {
|
||||||
if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally || Brainy.shutdownSignalHandlerActive) {
|
if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (Brainy.sigtermListener) process.off('SIGTERM', Brainy.sigtermListener)
|
if (Brainy.sigtermListener) process.off('SIGTERM', Brainy.sigtermListener)
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,6 @@ describe('API Parameter Validation', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should use "where" parameter for metadata filtering', async () => {
|
it('should use "where" parameter for metadata filtering', async () => {
|
||||||
const results = await brain.find({
|
const results = await brain.find({
|
||||||
where: { category: 'test-category' },
|
where: { category: 'test-category' },
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* - Backward compatibility preserved
|
* - Backward compatibility preserved
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { NounType } from '../../src/types/graphTypes.js'
|
import { NounType } from '../../src/types/graphTypes.js'
|
||||||
|
|
||||||
|
|
@ -19,10 +19,6 @@ describe('Entity Confidence & Weight Exposure', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Entity interface', () => {
|
describe('Entity interface', () => {
|
||||||
it('should expose confidence when adding entity with confidence', async () => {
|
it('should expose confidence when adding entity with confidence', async () => {
|
||||||
const id = await brain.add({
|
const id = await brain.add({
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
* index-served (a body field, or a bucketed timestamp), exactly the owing rows
|
* index-served (a body field, or a bucketed timestamp), exactly the owing rows
|
||||||
* are read and the rest are still served from the index.
|
* are read and the rest are still served from the index.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy'
|
import { Brainy } from '../../src/brainy'
|
||||||
import { NounType } from '../../src/types/graphTypes'
|
import { NounType } from '../../src/types/graphTypes'
|
||||||
import { generateTestVector } from '../helpers/test-factory'
|
import { generateTestVector } from '../helpers/test-factory'
|
||||||
|
|
@ -59,10 +59,6 @@ describe('find/get({ fields }) — projection', () => {
|
||||||
await brain.flush()
|
await brain.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Count canonical record reads for one call. */
|
/** Count canonical record reads for one call. */
|
||||||
const countingReads = async <R>(body: () => Promise<R>): Promise<{ out: R; reads: number }> => {
|
const countingReads = async <R>(body: () => Promise<R>): Promise<{ out: R; reads: number }> => {
|
||||||
const spy = vi.spyOn(brain as any, 'batchGet')
|
const spy = vi.spyOn(brain as any, 'batchGet')
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
* never the legs. And the text leg is asked about the universe's ids only —
|
* never the legs. And the text leg is asked about the universe's ids only —
|
||||||
* what it marshals is bounded by the universe, not by the store.
|
* what it marshals is bounded by the universe, not by the store.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy'
|
import { Brainy } from '../../src/brainy'
|
||||||
import { NounType, VerbType } from '../../src/types/graphTypes'
|
import { NounType, VerbType } from '../../src/types/graphTypes'
|
||||||
import { rankIndicesByScore, reorderByIndices } from '../../src/utils/resultRanking'
|
import { rankIndicesByScore, reorderByIndices } from '../../src/utils/resultRanking'
|
||||||
|
|
@ -287,10 +287,6 @@ describe('hybrid find: filter before hydrate — the answer is unchanged', () =>
|
||||||
expect(typeof (brain as any).metadataIndex.getIdSetForFilter).not.toBe('function')
|
expect(typeof (brain as any).metadataIndex.getIdSetForFilter).not.toBe('function')
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the fixture does not truncate the text leg — the universe covers every text match', async () => {
|
it('the fixture does not truncate the text leg — the universe covers every text match', async () => {
|
||||||
const index = (brain as any).metadataIndex
|
const index = (brain as any).metadataIndex
|
||||||
const textMatches = await index.getIdsForTextQuery(QUERY)
|
const textMatches = await index.getIdsForTextQuery(QUERY)
|
||||||
|
|
@ -557,10 +553,6 @@ describe('hybrid find: the text leg ranks inside the filter, not around it', ()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the old order let the filter consume the whole text leg', async () => {
|
it('the old order let the filter consume the whole text leg', async () => {
|
||||||
const index = (brain as any).metadataIndex
|
const index = (brain as any).metadataIndex
|
||||||
const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' })
|
const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' })
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* it). Now the anchor is fetched with its vector, and an anchor without one
|
* it). Now the anchor is fetched with its vector, and an anchor without one
|
||||||
* refuses by name instead of failing inside the index.
|
* refuses by name instead of failing inside the index.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy'
|
import { Brainy } from '../../src/brainy'
|
||||||
import { NounType } from '../../src/types/graphTypes'
|
import { NounType } from '../../src/types/graphTypes'
|
||||||
import { v5 } from '../../src/universal/uuid'
|
import { v5 } from '../../src/universal/uuid'
|
||||||
|
|
@ -28,10 +28,6 @@ describe('find({ near }) uses the anchor vector', () => {
|
||||||
await brain.add({ id: 'far', data: 'far row', type: NounType.Thing, vector: generateTestVector() })
|
await brain.add({ id: 'far', data: 'far row', type: NounType.Thing, vector: generateTestVector() })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the anchor\'s neighbours by its own vector', async () => {
|
it('returns the anchor\'s neighbours by its own vector', async () => {
|
||||||
const results = await brain.find({ near: { id: 'anchor' }, limit: 3 })
|
const results = await brain.find({ near: { id: 'anchor' }, limit: 3 })
|
||||||
expect(results.length).toBeGreaterThan(0)
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
* the covering is ASSERTED from the leg's own output rather than assumed. This
|
* the covering is ASSERTED from the leg's own output rather than assumed. This
|
||||||
* pin is about ordering, and it says nothing about recall.
|
* pin is about ordering, and it says nothing about recall.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy'
|
import { Brainy } from '../../src/brainy'
|
||||||
import { NounType, VerbType } from '../../src/types/graphTypes'
|
import { NounType, VerbType } from '../../src/types/graphTypes'
|
||||||
import { resolveEntityId } from '../../src/utils/idNormalization'
|
import { resolveEntityId } from '../../src/utils/idNormalization'
|
||||||
|
|
@ -107,10 +107,6 @@ describe('find(): orderBy is the order on every path', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the fixture: the hybrid candidate set covers the whole filter universe', async () => {
|
it('the fixture: the hybrid candidate set covers the whole filter universe', async () => {
|
||||||
const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' })
|
const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' })
|
||||||
expect(universe).toHaveLength(ROWS)
|
expect(universe).toHaveLength(ROWS)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
* against the adjacency before it is believed, so a not-serving graph refuses
|
* against the adjacency before it is believed, so a not-serving graph refuses
|
||||||
* loudly instead of answering `[]` as truth.
|
* loudly instead of answering `[]` as truth.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy'
|
import { Brainy } from '../../src/brainy'
|
||||||
import { NounType, VerbType } from '../../src/types/graphTypes'
|
import { NounType, VerbType } from '../../src/types/graphTypes'
|
||||||
import { generateTestVector } from '../helpers/test-factory'
|
import { generateTestVector } from '../helpers/test-factory'
|
||||||
|
|
@ -56,10 +56,6 @@ describe('find(): the optional planner door', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Install a planner door for one call, then remove it. */
|
/** Install a planner door for one call, then remove it. */
|
||||||
const withDoor = async <T>(
|
const withDoor = async <T>(
|
||||||
door: (...a: any[]) => Promise<any>,
|
door: (...a: any[]) => Promise<any>,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ describe('Unified Find() Integration Tests', () => {
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await cleanup.cleanup()
|
await cleanup.cleanup()
|
||||||
await brain.close()
|
|
||||||
brain = null as any
|
brain = null as any
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
* All entities carry explicit 384-dim vectors so no test invokes the embedder.
|
* All entities carry explicit 384-dim vectors so no test invokes the embedder.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, afterEach } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
||||||
import { v5, v7, isUUID } from '../../src/universal/uuid.js'
|
import { v5, v7, isUUID } from '../../src/universal/uuid.js'
|
||||||
|
|
@ -37,15 +37,8 @@ async function makeBrain(): Promise<Brainy> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('id normalization — transparent string-key round-trips', () => {
|
describe('id normalization — transparent string-key round-trips', () => {
|
||||||
const opened: Brainy[] = []
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const b of opened.splice(0)) await b.close().catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1. add() returns v5(key); get(key) and get(returnedId) both resolve; _originalId preserved', async () => {
|
it('1. add() returns v5(key); get(key) and get(returnedId) both resolve; _originalId preserved', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
const returnedId = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
const returnedId = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
||||||
|
|
||||||
|
|
@ -67,7 +60,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('2. relate() by string keys; related(key) and related({from:key}) return the edge to v5(toKey)', async () => {
|
it('2. relate() by string keys; related(key) and related({from:key}) return the edge to v5(toKey)', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
||||||
await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document })
|
await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document })
|
||||||
|
|
@ -93,7 +85,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('3. update() by string key reflects on get(key)', async () => {
|
it('3. update() by string key reflects on get(key)', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { role: 'admin' } })
|
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { role: 'admin' } })
|
||||||
await brain.update({ id: 'user-1', metadata: { role: 'owner' } })
|
await brain.update({ id: 'user-1', metadata: { role: 'owner' } })
|
||||||
|
|
@ -107,7 +98,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('4. remove() by string key deletes; get(key) is null', async () => {
|
it('4. remove() by string key deletes; get(key) is null', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
||||||
expect(await brain.get('user-1')).not.toBeNull()
|
expect(await brain.get('user-1')).not.toBeNull()
|
||||||
|
|
@ -120,7 +110,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('5. find({ connected: { from: key } }) resolves the anchor key', async () => {
|
it('5. find({ connected: { from: key } }) resolves the anchor key', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
||||||
await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document })
|
await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document })
|
||||||
|
|
@ -133,7 +122,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('6. transact() add+relate by string keys round-trips with consistent canonical ids', async () => {
|
it('6. transact() add+relate by string keys round-trips with consistent canonical ids', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
// Seed user-1 so the relate op has a target to point at.
|
// Seed user-1 so the relate op has a target to point at.
|
||||||
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person })
|
||||||
|
|
@ -161,7 +149,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('7. addMany() + relateMany() with string ids round-trip', async () => {
|
it('7. addMany() + relateMany() with string ids round-trip', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
const added = await brain.addMany({
|
const added = await brain.addMany({
|
||||||
items: [
|
items: [
|
||||||
|
|
@ -188,7 +175,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('8. determinism: same key maps to same UUID — two adds upsert ONE entity, not two', async () => {
|
it('8. determinism: same key maps to same UUID — two adds upsert ONE entity, not two', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
const id1 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 1 } })
|
const id1 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 1 } })
|
||||||
const id2 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 2 } })
|
const id2 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 2 } })
|
||||||
|
|
@ -207,7 +193,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('9. valid-UUID passthrough: a real UUID is kept verbatim with NO _originalId', async () => {
|
it('9. valid-UUID passthrough: a real UUID is kept verbatim with NO _originalId', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
const realUuid = v7()
|
const realUuid = v7()
|
||||||
const returnedId = await brain.add({ id: realUuid, vector: vec(5), type: NounType.Thing })
|
const returnedId = await brain.add({ id: realUuid, vector: vec(5), type: NounType.Thing })
|
||||||
|
|
@ -222,7 +207,6 @@ describe('id normalization — transparent string-key round-trips', () => {
|
||||||
|
|
||||||
it('10. no-id add() mints a v7; newId() mints a v7', async () => {
|
it('10. no-id add() mints a v7; newId() mints a v7', async () => {
|
||||||
const brain = await makeBrain()
|
const brain = await makeBrain()
|
||||||
opened.push(brain)
|
|
||||||
|
|
||||||
const autoId = await brain.add({ vector: vec(6), type: NounType.Thing })
|
const autoId = await brain.add({ vector: vec(6), type: NounType.Thing })
|
||||||
expect(isUUID(autoId)).toBe(true)
|
expect(isUUID(autoId)).toBe(true)
|
||||||
|
|
|
||||||
|
|
@ -107,11 +107,7 @@ describe('Multi-process safety + read-only mode', () => {
|
||||||
|
|
||||||
const blocked = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
|
const blocked = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
|
||||||
await expect(blocked.init()).rejects.toThrow(/another writer holds/i)
|
await expect(blocked.init()).rejects.toThrow(/another writer holds/i)
|
||||||
// A rejected init() still registered `blocked` in Brainy's global
|
// Don't track `blocked` for afterEach cleanup since init failed.
|
||||||
// instance registry (the constructor does that unconditionally) — close()
|
|
||||||
// is safe to call even though init() never completed, and is what
|
|
||||||
// deregisters it (and, once idle, the process-level shutdown hooks).
|
|
||||||
await blocked.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('takes over a STALE foreign lock (dead PID + old heartbeat) and claims atomically', async () => {
|
it('takes over a STALE foreign lock (dead PID + old heartbeat) and claims atomically', async () => {
|
||||||
|
|
@ -155,7 +151,6 @@ describe('Multi-process safety + read-only mode', () => {
|
||||||
const err: any = await blocked.init().catch((e) => e)
|
const err: any = await blocked.init().catch((e) => e)
|
||||||
expect(err.code).toBe('BRAINY_WRITER_LOCKED')
|
expect(err.code).toBe('BRAINY_WRITER_LOCKED')
|
||||||
expect(err.lockInfo?.pid).toBe(otherPid)
|
expect(err.lockInfo?.pid).toBe(otherPid)
|
||||||
await blocked.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('release drains an in-flight heartbeat — no phantom lock re-created after unlink', async () => {
|
it('release drains an in-flight heartbeat — no phantom lock re-created after unlink', async () => {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ describe('related() with a verb-type array returns every requested type', () =>
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await brain.close()
|
|
||||||
brain = null as any
|
brain = null as any
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,7 @@ describe('Relationship Intelligence', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(() => {
|
||||||
await brain.close()
|
|
||||||
if (fs.existsSync(testDir)) {
|
if (fs.existsSync(testDir)) {
|
||||||
fs.rmSync(testDir, { recursive: true })
|
fs.rmSync(testDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* - addMany({ ifAbsent: true }) applies the flag to every item
|
* - addMany({ ifAbsent: true }) applies the flag to every item
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { RevisionConflictError } from '../../src/transaction/RevisionConflictError.js'
|
import { RevisionConflictError } from '../../src/transaction/RevisionConflictError.js'
|
||||||
import { NounType } from '../../src/types/graphTypes.js'
|
import { NounType } from '../../src/types/graphTypes.js'
|
||||||
|
|
@ -22,10 +22,6 @@ describe('7.31.0 — _rev CAS + ifAbsent', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('_rev initialization + surface', () => {
|
describe('_rev initialization + surface', () => {
|
||||||
it('initializes _rev to 1 on add()', async () => {
|
it('initializes _rev to 1 on add()', async () => {
|
||||||
const id = await brain.add({ data: 'hello', type: NounType.Document })
|
const id = await brain.add({ data: 'hello', type: NounType.Document })
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ describe('repairContainment: batched pass 2', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await brain.close()
|
|
||||||
brain = null as any
|
brain = null as any
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@ import * as XLSX from 'xlsx'
|
||||||
describe('VFS Debug', () => {
|
describe('VFS Debug', () => {
|
||||||
it('minimal VFS writeFile test', async () => {
|
it('minimal VFS writeFile test', async () => {
|
||||||
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
||||||
try {
|
await brain.init()
|
||||||
await brain.init()
|
|
||||||
|
|
||||||
console.log('✅ Brain initialized')
|
console.log('✅ Brain initialized')
|
||||||
|
|
||||||
// Get VFS and initialize
|
// Get VFS and initialize
|
||||||
const vfs = brain.vfs
|
const vfs = brain.vfs
|
||||||
|
|
@ -78,8 +77,5 @@ describe('VFS Debug', () => {
|
||||||
// THE REAL TEST: Can we query VFS?
|
// THE REAL TEST: Can we query VFS?
|
||||||
expect(children.length).toBeGreaterThan(0)
|
expect(children.length).toBeGreaterThan(0)
|
||||||
expect(rootContents.length).toBeGreaterThan(0)
|
expect(rootContents.length).toBeGreaterThan(0)
|
||||||
} finally {
|
|
||||||
await brain.close()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ describe('writer-lock fencing', () => {
|
||||||
// Old rule: heartbeat-age eviction → silent takeover → split brain.
|
// Old rule: heartbeat-age eviction → silent takeover → split brain.
|
||||||
// New rule: live PID = live writer; the second opener throws typed.
|
// New rule: live PID = live writer; the second opener throws typed.
|
||||||
const second = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false })
|
const second = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false })
|
||||||
brains.push(second)
|
|
||||||
await expect(second.init()).rejects.toMatchObject({ code: 'BRAINY_WRITER_LOCKED' })
|
await expect(second.init()).rejects.toMatchObject({ code: 'BRAINY_WRITER_LOCKED' })
|
||||||
}, 120000)
|
}, 120000)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
* - Note limitations and edge cases
|
* - Note limitations and edge cases
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { TypeAwareStorageAdapter } from '../../src/storage/adapters/typeAwareStorageAdapter.js'
|
import { TypeAwareStorageAdapter } from '../../src/storage/adapters/typeAwareStorageAdapter.js'
|
||||||
import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
|
import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
|
||||||
|
|
@ -67,10 +67,6 @@ describe('TypeAware Performance Benchmarks', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brainMemory.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should measure type-based query performance', async () => {
|
it('should measure type-based query performance', async () => {
|
||||||
// MEASURED: Query for one type (200 entities)
|
// MEASURED: Query for one type (200 entities)
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* No mocks, no fakes, real implementation
|
* No mocks, no fakes, real implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { NounType } from '../../src/types/graphTypes.js'
|
import { NounType } from '../../src/types/graphTypes.js'
|
||||||
|
|
||||||
|
|
@ -21,10 +21,6 @@ describe('Brainy 3.0 Core (Unit Tests)', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CRUD Operations', () => {
|
describe('CRUD Operations', () => {
|
||||||
it('should create items with add', async () => {
|
it('should create items with add', async () => {
|
||||||
const id = await brain.add({
|
const id = await brain.add({
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,13 @@ import { prodLog } from '../../../src/utils/logger.js'
|
||||||
const UUID = (suffix: string): string => `00000000-0000-4000-8000-0000000000${suffix}`
|
const UUID = (suffix: string): string => `00000000-0000-4000-8000-0000000000${suffix}`
|
||||||
|
|
||||||
describe('Finding 10 — degraded derived-index state is surfaced on reads', () => {
|
describe('Finding 10 — degraded derived-index state is surfaced on reads', () => {
|
||||||
const opened: Brainy[] = []
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||||||
})
|
})
|
||||||
afterEach(async () => {
|
afterEach(() => vi.restoreAllMocks())
|
||||||
vi.restoreAllMocks()
|
|
||||||
for (const b of opened.splice(0)) await b.close().catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('checkHealth() reports adopt-forward degraded ids as unhealthy', async () => {
|
it('checkHealth() reports adopt-forward degraded ids as unhealthy', async () => {
|
||||||
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
|
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
|
||||||
opened.push(brain)
|
|
||||||
await brain.init()
|
await brain.init()
|
||||||
;(brain as any)._indexDegradedIds.add(UUID('de'))
|
;(brain as any)._indexDegradedIds.add(UUID('de'))
|
||||||
|
|
||||||
|
|
@ -43,7 +37,6 @@ describe('Finding 10 — degraded derived-index state is surfaced on reads', ()
|
||||||
it('find()/get() warn loudly while degraded, ONCE, then repairIndex() clears it', async () => {
|
it('find()/get() warn loudly while degraded, ONCE, then repairIndex() clears it', async () => {
|
||||||
const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {})
|
const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {})
|
||||||
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
|
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
|
||||||
opened.push(brain)
|
|
||||||
await brain.init()
|
await brain.init()
|
||||||
await brain.add({ id: UUID('a1'), data: 'x', type: NounType.Document })
|
await brain.add({ id: UUID('a1'), data: 'x', type: NounType.Document })
|
||||||
;(brain as any)._indexRebuildFailed = new Error('rebuild boom')
|
;(brain as any)._indexRebuildFailed = new Error('rebuild boom')
|
||||||
|
|
@ -66,7 +59,6 @@ describe('Finding 10 — degraded derived-index state is surfaced on reads', ()
|
||||||
|
|
||||||
it('persistSingleOp records receipt.degraded (widened return type, not dropped)', async () => {
|
it('persistSingleOp records receipt.degraded (widened return type, not dropped)', async () => {
|
||||||
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
|
const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false })
|
||||||
opened.push(brain)
|
|
||||||
await brain.init()
|
await brain.init()
|
||||||
// Simulate a degraded receipt by wrapping the generation store's commitSingleOp.
|
// Simulate a degraded receipt by wrapping the generation store's commitSingleOp.
|
||||||
const gs: any = (brain as any).generationStore
|
const gs: any = (brain as any).generationStore
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* soft-delete semantic: `field !== value` MUST include entities that have no
|
* soft-delete semantic: `field !== value` MUST include entities that have no
|
||||||
* such field at all.
|
* such field at all.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../../src/brainy'
|
import { Brainy } from '../../../src/brainy'
|
||||||
import { NounType } from '../../../src/types/graphTypes'
|
import { NounType } from '../../../src/types/graphTypes'
|
||||||
|
|
||||||
|
|
@ -26,10 +26,6 @@ describe('find() complement operators (ne / exists:false / missing:true)', () =>
|
||||||
ids.noField2 = await brain.add({ data: 'n2', type: NounType.Thing, metadata: { other: 2 } })
|
ids.noField2 = await brain.add({ data: 'n2', type: NounType.Thing, metadata: { other: 2 } })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ne returns everything except the matching value — INCLUDING entities without the field', async () => {
|
it('ne returns everything except the matching value — INCLUDING entities without the field', async () => {
|
||||||
const rows = await brain.find({ where: { status: { ne: 'active' } }, limit: 100 })
|
const rows = await brain.find({ where: { status: { ne: 'active' } }, limit: 100 })
|
||||||
const got = new Set(rows.map((r) => r.id))
|
const got = new Set(rows.map((r) => r.id))
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
* returns an id whose record matches NEITHER the type nor the where filter) and
|
* returns an id whose record matches NEITHER the type nor the where filter) and
|
||||||
* assert the phantom is dropped while the genuine matches survive.
|
* assert the phantom is dropped while the genuine matches survive.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../../src/brainy'
|
import { Brainy } from '../../../src/brainy'
|
||||||
import { NounType } from '../../../src/types/graphTypes'
|
import { NounType } from '../../../src/types/graphTypes'
|
||||||
|
|
||||||
|
|
@ -48,10 +48,6 @@ describe('find() index-integrity guard (phantom row class)', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('healthy index: the discriminant query returns only the staff Person', async () => {
|
it('healthy index: the discriminant query returns only the staff Person', async () => {
|
||||||
const rows = await brain.find({ type: NounType.Person, where: { entityType: 'staff' }, limit: 100 })
|
const rows = await brain.find({ type: NounType.Person, where: { entityType: 'staff' }, limit: 100 })
|
||||||
expect(rows.map((r) => r.id)).toEqual([staffId])
|
expect(rows.map((r) => r.id)).toEqual([staffId])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../../src/brainy'
|
import { Brainy } from '../../../src/brainy'
|
||||||
import { createAddParams } from '../../helpers/test-factory'
|
import { createAddParams } from '../../helpers/test-factory'
|
||||||
import { NounType } from '../../../src/types/graphTypes'
|
import { NounType } from '../../../src/types/graphTypes'
|
||||||
|
|
@ -13,10 +13,6 @@ describe('Brainy.find()', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('success paths', () => {
|
describe('success paths', () => {
|
||||||
it('should find entities by text query', async () => {
|
it('should find entities by text query', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
* exercised by cor's combined matrix); they inject probe/spy hooks onto the live JS
|
* exercised by cor's combined matrix); they inject probe/spy hooks onto the live JS
|
||||||
* metadata index, which has neither method by default.
|
* metadata index, which has neither method by default.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../../src/brainy'
|
import { Brainy } from '../../../src/brainy'
|
||||||
import { NounType } from '../../../src/types/graphTypes'
|
import { NounType } from '../../../src/types/graphTypes'
|
||||||
|
|
||||||
|
|
@ -34,10 +34,6 @@ describe('metadata-provider contract wiring (getIdsForFilter opts)', () => {
|
||||||
mi = (brain as any).metadataIndex
|
mi = (brain as any).metadataIndex
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('RETIRED: a read never calls probeConsistency() / self-heals via detectAndRepairCorruption — that is the read-triggered dark rebuild the health-gate law forbids', async () => {
|
it('RETIRED: a read never calls probeConsistency() / self-heals via detectAndRepairCorruption — that is the read-triggered dark rebuild the health-gate law forbids', async () => {
|
||||||
let probes = 0
|
let probes = 0
|
||||||
let repairs = 0
|
let repairs = 0
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* gate that hung getStats / readdir / readFile behind an unrelated family's
|
* gate that hung getStats / readdir / readFile behind an unrelated family's
|
||||||
* migration until the wait timed out.
|
* migration until the wait timed out.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../../src/brainy.js'
|
import { Brainy } from '../../../src/brainy.js'
|
||||||
import { MigrationInProgressError } from '../../../src/errors/brainyError.js'
|
import { MigrationInProgressError } from '../../../src/errors/brainyError.js'
|
||||||
|
|
||||||
|
|
@ -38,19 +38,12 @@ const jam = (provider: unknown) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('migration LOCK is family-scoped', () => {
|
describe('migration LOCK is family-scoped', () => {
|
||||||
const opened: Brainy[] = []
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const b of opened.splice(0)) await b.close().catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a stuck VECTOR migration does not block canonical or graph/metadata reads', async () => {
|
it('a stuck VECTOR migration does not block canonical or graph/metadata reads', async () => {
|
||||||
const brain = await seed()
|
const brain = await seed()
|
||||||
opened.push(brain)
|
|
||||||
const childId = (
|
const childId = (
|
||||||
(await brain.vfs.readdir('/notes', { withFileTypes: true })) as Array<{ entityId: string }>
|
(await brain.vfs.readdir('/notes', { withFileTypes: true })) as Array<{ entityId: string }>
|
||||||
)[0].entityId
|
)[0].entityId
|
||||||
|
|
@ -67,7 +60,6 @@ describe('migration LOCK is family-scoped', () => {
|
||||||
|
|
||||||
it('a stuck VECTOR migration STILL blocks a read that needs the vector family', async () => {
|
it('a stuck VECTOR migration STILL blocks a read that needs the vector family', async () => {
|
||||||
const brain = await seed()
|
const brain = await seed()
|
||||||
opened.push(brain)
|
|
||||||
jam((brain as any).index)
|
jam((brain as any).index)
|
||||||
|
|
||||||
// A semantic query consults the vector index — it must wait, and (bounded by
|
// A semantic query consults the vector index — it must wait, and (bounded by
|
||||||
|
|
@ -78,7 +70,6 @@ describe('migration LOCK is family-scoped', () => {
|
||||||
|
|
||||||
it('a stuck GRAPH migration blocks traversal but not vector/canonical reads', async () => {
|
it('a stuck GRAPH migration blocks traversal but not vector/canonical reads', async () => {
|
||||||
const brain = await seed()
|
const brain = await seed()
|
||||||
opened.push(brain)
|
|
||||||
const childId = (
|
const childId = (
|
||||||
(await brain.vfs.readdir('/notes', { withFileTypes: true })) as Array<{ entityId: string }>
|
(await brain.vfs.readdir('/notes', { withFileTypes: true })) as Array<{ entityId: string }>
|
||||||
)[0].entityId
|
)[0].entityId
|
||||||
|
|
@ -96,7 +87,6 @@ describe('migration LOCK is family-scoped', () => {
|
||||||
|
|
||||||
it('with no migration in flight, every read serves (the fast path is a no-op)', async () => {
|
it('with no migration in flight, every read serves (the fast path is a no-op)', async () => {
|
||||||
const brain = await seed()
|
const brain = await seed()
|
||||||
opened.push(brain)
|
|
||||||
await expect(brain.getStats()).resolves.toBeDefined()
|
await expect(brain.getStats()).resolves.toBeDefined()
|
||||||
await expect(brain.find({ query: 'doc' })).resolves.toBeDefined()
|
await expect(brain.find({ query: 'doc' })).resolves.toBeDefined()
|
||||||
await expect(brain.vfs.readdir('/notes')).resolves.toHaveLength(1)
|
await expect(brain.vfs.readdir('/notes')).resolves.toHaveLength(1)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ describe('Duplicate Check Optimization', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await brain.close()
|
// Cleanup is automatic with memory storage
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should detect duplicate relationships using GraphAdjacencyIndex', async () => {
|
it('should detect duplicate relationships using GraphAdjacencyIndex', async () => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* _indexRebuildFailed / _indexDegradedIds degraded states (mirroring
|
* _indexRebuildFailed / _indexDegradedIds degraded states (mirroring
|
||||||
* validateIndexConsistency / checkHealth).
|
* validateIndexConsistency / checkHealth).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType } from '../../src/index.js'
|
import { Brainy, NounType } from '../../src/index.js'
|
||||||
|
|
||||||
describe('getIndexStatus honest readiness (Finding 9)', () => {
|
describe('getIndexStatus honest readiness (Finding 9)', () => {
|
||||||
|
|
@ -20,10 +20,6 @@ describe('getIndexStatus honest readiness (Finding 9)', () => {
|
||||||
await brain.flush()
|
await brain.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a not-ready provider makes populated honest (false) and exposes ready:false', async () => {
|
it('a not-ready provider makes populated honest (false) and exposes ready:false', async () => {
|
||||||
brain.index.isReady = () => false // count present, serving structure NOT loaded
|
brain.index.isReady = () => false // count present, serving structure NOT loaded
|
||||||
const status = await brain.getIndexStatus()
|
const status = await brain.getIndexStatus()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* scan; and a one-shot probe self-heals a no-isReady provider whose adjacency
|
* scan; and a one-shot probe self-heals a no-isReady provider whose adjacency
|
||||||
* did not cold-load.
|
* did not cold-load.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType, VerbType } from '../../../src/index.js'
|
import { Brainy, NounType, VerbType } from '../../../src/index.js'
|
||||||
|
|
||||||
describe('graph fast-path honest readiness (Finding 2)', () => {
|
describe('graph fast-path honest readiness (Finding 2)', () => {
|
||||||
|
|
@ -33,10 +33,6 @@ describe('graph fast-path honest readiness (Finding 2)', () => {
|
||||||
await storage.getVerbsBySource(a)
|
await storage.getVerbsBySource(a)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('not-ready provider → shard scan returns the REAL edges, not a silent []', async () => {
|
it('not-ready provider → shard scan returns the REAL edges, not a silent []', async () => {
|
||||||
const gi = storage.graphIndex
|
const gi = storage.graphIndex
|
||||||
// Simulate a cold native provider: count/manifest loaded (isInitialized) but
|
// Simulate a cold native provider: count/manifest loaded (isInitialized) but
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* The 8.0 JS index cold-loads correctly, so we simulate the cold native failure
|
* The 8.0 JS index cold-loads correctly, so we simulate the cold native failure
|
||||||
* mode by intercepting the provider's getIdsForFilter/rebuild.
|
* mode by intercepting the provider's getIdsForFilter/rebuild.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType, MetadataIndexNotReadyError } from '../../src/index.js'
|
import { Brainy, NounType, MetadataIndexNotReadyError } from '../../src/index.js'
|
||||||
|
|
||||||
const V = () => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001)
|
const V = () => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001)
|
||||||
|
|
@ -31,10 +31,6 @@ describe('Metadata cold-read guard (#venue silent-[])', () => {
|
||||||
await brain.flush()
|
await brain.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('warm brain: filtered find is correct and the guard does not rebuild', async () => {
|
it('warm brain: filtered find is correct and the guard does not rebuild', async () => {
|
||||||
const mi = brain.metadataIndex
|
const mi = brain.metadataIndex
|
||||||
let rebuilds = 0
|
let rebuilds = 0
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
* the production feature-detection reads it.
|
* the production feature-detection reads it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType, MigrationInProgressError } from '../../src/index.js'
|
import { Brainy, NounType, MigrationInProgressError } from '../../src/index.js'
|
||||||
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
|
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
|
||||||
|
|
||||||
|
|
@ -39,12 +39,6 @@ describe('Migration LOCK (#18) — coordinated 7.x→8.0 auto-upgrade', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
// The "close() is not gated" test already closes `brain` itself as its
|
|
||||||
// own assertion — closing an already-closed brain is a safe no-op here.
|
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not gate operations when no provider is migrating (fast path)', async () => {
|
it('does not gate operations when no provider is migrating (fast path)', async () => {
|
||||||
const id = await brain.add({ data: 'hello', type: NounType.Concept })
|
const id = await brain.add({ data: 'hello', type: NounType.Concept })
|
||||||
expect(id).toBeTruthy()
|
expect(id).toBeTruthy()
|
||||||
|
|
@ -136,9 +130,6 @@ describe('Migration LOCK (#18) — coordinated 7.x→8.0 auto-upgrade', () => {
|
||||||
expect(e).toBeInstanceOf(MigrationInProgressError)
|
expect(e).toBeInstanceOf(MigrationInProgressError)
|
||||||
expect(e.retryable).toBe(true)
|
expect(e.retryable).toBe(true)
|
||||||
expect(typeof e.elapsedMs).toBe('number')
|
expect(typeof e.elapsedMs).toBe('number')
|
||||||
} finally {
|
|
||||||
// close() is proven not-gated by the test below — safe even mid-migration.
|
|
||||||
await shortBrain.close()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,10 @@ describe('EmbeddingSignal', () => {
|
||||||
signal = new EmbeddingSignal(brain)
|
signal = new EmbeddingSignal(brain)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(() => {
|
||||||
signal.clearCache()
|
signal.clearCache()
|
||||||
signal.clearHistory()
|
signal.clearHistory()
|
||||||
signal.resetStats()
|
signal.resetStats()
|
||||||
await brain.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('initialization', () => {
|
describe('initialization', () => {
|
||||||
|
|
|
||||||
|
|
@ -89,14 +89,12 @@ describe('Guarded plugin auto-detection (plugins: undefined)', () => {
|
||||||
})
|
})
|
||||||
const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
||||||
await expect(brain.init()).rejects.toThrow(/installed but failed to load/)
|
await expect(brain.init()).rejects.toThrow(/installed but failed to load/)
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('installed but not a valid plugin (missing activate) → init() throws', async () => {
|
it('installed but not a valid plugin (missing activate) → init() throws', async () => {
|
||||||
stubImport(async () => ({ default: { name: '@soulcraft/cor' } })) // no activate()
|
stubImport(async () => ({ default: { name: '@soulcraft/cor' } })) // no activate()
|
||||||
const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
||||||
await expect(brain.init()).rejects.toThrow(/not a valid Brainy plugin/)
|
await expect(brain.init()).rejects.toThrow(/not a valid Brainy plugin/)
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('installed but activation fails → init() throws (activateAll posture applies)', async () => {
|
it('installed but activation fails → init() throws (activateAll posture applies)', async () => {
|
||||||
|
|
@ -110,7 +108,6 @@ describe('Guarded plugin auto-detection (plugins: undefined)', () => {
|
||||||
}))
|
}))
|
||||||
const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
||||||
await expect(brain.init()).rejects.toThrow(/failed to activate/)
|
await expect(brain.init()).rejects.toThrow(/failed to activate/)
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('plugins: [] and plugins: false → no probe at all (explicit opt-out)', async () => {
|
it('plugins: [] and plugins: false → no probe at all (explicit opt-out)', async () => {
|
||||||
|
|
@ -135,6 +132,5 @@ describe('Guarded plugin auto-detection (plugins: undefined)', () => {
|
||||||
silent: true
|
silent: true
|
||||||
})
|
})
|
||||||
await expect(brain.init()).rejects.toThrow(/listed in config\.plugins but could not be loaded/)
|
await expect(brain.init()).rejects.toThrow(/listed in config\.plugins but could not be loaded/)
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,5 @@ describe('version coupling at init() — no silent fallback', () => {
|
||||||
plugins: ['@soulcraft/this-package-does-not-exist-xyz']
|
plugins: ['@soulcraft/this-package-does-not-exist-xyz']
|
||||||
})
|
})
|
||||||
await expect(brain.init()).rejects.toThrow(/could not be loaded|config\.plugins/)
|
await expect(brain.init()).rejects.toThrow(/could not be loaded|config\.plugins/)
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -298,10 +298,9 @@ describe('Brainy plugin integration', () => {
|
||||||
// must surface as a failed init(), NOT a silent degrade to the default
|
// must surface as a failed init(), NOT a silent degrade to the default
|
||||||
// engine (the version-coupling guard; see plugin-version-coupling.test.ts).
|
// engine (the version-coupling guard; see plugin-version-coupling.test.ts).
|
||||||
await expect(brain.init()).rejects.toThrow(/failed to activate|native module not found/)
|
await expect(brain.init()).rejects.toThrow(/failed to activate|native module not found/)
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use() return this for chaining', async () => {
|
it('should use() return this for chaining', () => {
|
||||||
const plugin: BrainyPlugin = {
|
const plugin: BrainyPlugin = {
|
||||||
name: 'chain-test',
|
name: 'chain-test',
|
||||||
activate: async () => true
|
activate: async () => true
|
||||||
|
|
@ -310,8 +309,5 @@ describe('Brainy plugin integration', () => {
|
||||||
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
||||||
const result = brain.use(plugin)
|
const result = brain.use(plugin)
|
||||||
expect(result).toBe(brain)
|
expect(result).toBe(brain)
|
||||||
// Never init()'d — the constructor still registered it in Brainy's global
|
|
||||||
// instance registry, so it still needs a close() to deregister.
|
|
||||||
await brain.close().catch(() => {})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,12 @@
|
||||||
* The script's only real interface is its CLI (it has no importable
|
* 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
|
* 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
|
* these tests spawn it exactly as scripts/release.sh does: as a child
|
||||||
* process, against a fixture CHANGELOG and a throwaway local bare repo
|
* process, against a temp copy of a wall file and a fixture CHANGELOG,
|
||||||
* standing in for git@source.soulcraft.com:soulcraftlabs/releases.git
|
* never against the repo's real releases/*.json.
|
||||||
* (--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 { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
|
@ -28,10 +25,6 @@ function run(args: string[], cwd: string): { status: number; stdout: string; std
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
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. */
|
/** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */
|
||||||
|
|
@ -48,7 +41,11 @@ function buildChangelog(entries: Array<{ version: string; date: string; bullets:
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallFile(product: string, entries: unknown[]): string {
|
function wallFile(product: string, entries: unknown[]): string {
|
||||||
return JSON.stringify({ product, entries }, null, 2) + '\n'
|
return JSON.stringify(
|
||||||
|
{ product, entries, history: 'Earlier releases are recorded in CHANGELOG.md in this repository.' },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_ENTRY = {
|
const BASE_ENTRY = {
|
||||||
|
|
@ -60,84 +57,31 @@ const BASE_ENTRY = {
|
||||||
thumb: null,
|
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 <product>.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 <product>.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 dir: string
|
||||||
let remoteDir: string
|
|
||||||
let cacheDir: string
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-'))
|
dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-'))
|
||||||
// wall-entry.mjs is run with this dir as its cwd, standing in for the real
|
|
||||||
// developer checkout it reads its commit identity from (process.cwd()) —
|
|
||||||
// give it a repo-local identity the same way seedRemote gives one to the
|
|
||||||
// seed clone, so the suite is deterministic on a host with no global git
|
|
||||||
// config (a bare CI box) as much as one with a developer's own.
|
|
||||||
execFileSync('git', ['init', '-q', dir])
|
|
||||||
git(['config', 'user.name', 'Wall Entry Test'], dir)
|
|
||||||
git(['config', 'user.email', 'wall-entry-test@example.com'], dir)
|
|
||||||
remoteDir = initBareRemote()
|
|
||||||
cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
rmSync(remoteDir, { recursive: true, force: true })
|
|
||||||
rmSync(cacheDir, { recursive: true, force: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('wall-entry.mjs — generate + publish', () => {
|
describe('wall-entry.mjs — generate + prepend', () => {
|
||||||
it('derives headline from the first bullet and items from every bullet, hashes stripped, and pushes it to the remote', () => {
|
it('derives headline from the first bullet and items from every bullet, hashes stripped', () => {
|
||||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY])
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(dir, 'CHANGELOG.md'),
|
join(dir, 'CHANGELOG.md'),
|
||||||
buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]),
|
buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]),
|
||||||
)
|
)
|
||||||
|
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY]))
|
||||||
|
|
||||||
const result = run(
|
const result = run(
|
||||||
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||||
dir,
|
dir,
|
||||||
)
|
)
|
||||||
expect(result.status).toBe(0)
|
expect(result.status).toBe(0)
|
||||||
expect(result.stdout).toMatch(/wrote v10\.4\.12.*pushed/i)
|
|
||||||
|
|
||||||
const wall = readRemote(remoteDir, 'open-brainy')
|
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||||
expect(wall.entries).toHaveLength(2)
|
expect(wall.entries).toHaveLength(2)
|
||||||
expect(wall.entries[0]).toEqual({
|
expect(wall.entries[0]).toEqual({
|
||||||
version: '10.4.12',
|
version: '10.4.12',
|
||||||
|
|
@ -152,192 +96,68 @@ describe('wall-entry.mjs — generate + publish', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prepends newest-first — the new entry lands at index 0 ahead of every existing one', () => {
|
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(
|
||||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]))
|
join(dir, 'CHANGELOG.md'),
|
||||||
|
buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]),
|
||||||
|
)
|
||||||
|
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]))
|
||||||
|
|
||||||
run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir)
|
run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir)
|
||||||
|
|
||||||
const wall = readRemote(remoteDir, 'open-brainy')
|
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||||
expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10'])
|
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', () => {
|
it('derives no URL (null) for a product with no known public release-page pattern', () => {
|
||||||
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'] }]))
|
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }]))
|
||||||
|
writeFileSync(join(dir, 'wall.json'), wallFile('brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }]))
|
||||||
|
|
||||||
const result = run(
|
run(['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir)
|
||||||
['--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')
|
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||||
expect(wall.entries[0].url).toBe('https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.6')
|
expect(wall.entries[0].url).toBeNull()
|
||||||
expect(wall.entries[0].thumb).toBeNull()
|
expect(wall.entries[0].thumb).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses a product with no permalink pattern, naming the cure', () => {
|
it('refuses by name when the version is already present, and leaves the file untouched', () => {
|
||||||
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'] }]))
|
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 before = wallFile('open-brainy', [BASE_ENTRY])
|
||||||
|
writeFileSync(join(dir, 'wall.json'), before)
|
||||||
|
|
||||||
const result = run(
|
const result = run(
|
||||||
['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.status).toBe(1)
|
||||||
|
expect(result.stderr).toMatch(/refusing.*10\.4\.11.*already present/i)
|
||||||
|
expect(readFileSync(join(dir, 'wall.json'), 'utf8')).toBe(before) // untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the CHANGELOG has no entry yet for the target version', () => {
|
||||||
|
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }]))
|
||||||
|
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', []))
|
||||||
|
|
||||||
|
const result = run(
|
||||||
|
['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||||
dir,
|
dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.status).toBe(1)
|
expect(result.status).toBe(1)
|
||||||
expect(result.stderr).toMatch(/no CHANGELOG entry yet/i)
|
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', () => {
|
it('refuses a cross-product write when --product does not match the target file', () => {
|
||||||
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'] }]))
|
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }]))
|
||||||
|
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY]))
|
||||||
|
|
||||||
const result = run(
|
const result = run(
|
||||||
['--product', 'open-brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
['--product', 'brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||||
dir,
|
dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.status).toBe(1)
|
expect(result.status).toBe(1)
|
||||||
expect(result.stderr).toMatch(/product "brainy".*--product "open-brainy"/i)
|
expect(result.stderr).toMatch(/product "open-brainy".*--product "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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -349,28 +169,21 @@ describe('wall-entry.mjs — --check', () => {
|
||||||
expect(result.stdout).toMatch(/OK/)
|
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', () => {
|
it('catches a missing entry key', () => {
|
||||||
const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'] } // no "url"
|
const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'], url: null } // no "thumb"
|
||||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken]))
|
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken]))
|
||||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||||
expect(result.status).toBe(1)
|
expect(result.status).toBe(1)
|
||||||
expect(result.stderr).toMatch(/missing key\(s\) url/)
|
expect(result.stderr).toMatch(/missing key\(s\) thumb/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('catches an unexpected top-level key (e.g. the retired "history" field)', () => {
|
it('catches an unexpected top-level key', () => {
|
||||||
const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY]))
|
const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY]))
|
||||||
raw.history = 'retired field'
|
raw.extra = 'not allowed'
|
||||||
writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw))
|
writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw))
|
||||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||||
expect(result.status).toBe(1)
|
expect(result.status).toBe(1)
|
||||||
expect(result.stderr).toMatch(/unexpected key\(s\) history/)
|
expect(result.stderr).toMatch(/unexpected key\(s\) extra/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('catches entries that are not newest-first', () => {
|
it('catches entries that are not newest-first', () => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* hydration (zero per-entity reads when unfiltered). Both must preserve the exact
|
* hydration (zero per-entity reads when unfiltered). Both must preserve the exact
|
||||||
* pagination contract: same order, cursor continuation, filters, totalCount.
|
* pagination contract: same order, cursor continuation, filters, totalCount.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { Brainy, NounType } from '../../../src/index.js'
|
import { Brainy, NounType } from '../../../src/index.js'
|
||||||
|
|
||||||
describe('paginated enumeration — parallel hydration + id-only (cortex heal-cost)', () => {
|
describe('paginated enumeration — parallel hydration + id-only (cortex heal-cost)', () => {
|
||||||
|
|
@ -30,10 +30,6 @@ describe('paginated enumeration — parallel hydration + id-only (cortex heal-co
|
||||||
storage = brain.storage
|
storage = brain.storage
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Page the whole dataset through a small limit via cursor and collect ordered ids. */
|
/** Page the whole dataset through a small limit via cursor and collect ordered ids. */
|
||||||
const pageAll = async (fn: (opts: any) => Promise<any>, key: 'items' | 'ids') => {
|
const pageAll = async (fn: (opts: any) => Promise<any>, key: 'items' | 'ids') => {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Tests to verify that brain.find({ type: NounType.X }) correctly filters entities
|
* Tests to verify that brain.find({ type: NounType.X }) correctly filters entities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType } from '../../src/index.js'
|
import { Brainy, NounType } from '../../src/index.js'
|
||||||
|
|
||||||
describe('Type Filtering (A Consumer Team Issue)', () => {
|
describe('Type Filtering (A Consumer Team Issue)', () => {
|
||||||
|
|
@ -17,10 +17,6 @@ describe('Type Filtering (A Consumer Team Issue)', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should filter entities by NounType.Person', async () => {
|
it('should filter entities by NounType.Person', async () => {
|
||||||
// Add 3 people
|
// Add 3 people
|
||||||
await brain.add({ data: 'John Smith', type: NounType.Person, metadata: { name: 'John' } })
|
await brain.add({ data: 'John Smith', type: NounType.Person, metadata: { name: 'John' } })
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,6 @@ describe('the indexable-array bound', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('BELOW the bound: the array indexes, every element of it', () => {
|
describe('BELOW the bound: the array indexes, every element of it', () => {
|
||||||
it('the eleven-element array that used to vanish is searchable', async () => {
|
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.
|
// ELEVEN — one over the old silent limit, the whole shape of the defect.
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
* column store adopts the field. It is named in `getIdsFromChunksForRange`'s
|
* column store adopts the field. It is named in `getIdsFromChunksForRange`'s
|
||||||
* doc comment rather than papered over.
|
* doc comment rather than papered over.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../../src/brainy'
|
import { Brainy } from '../../../src/brainy'
|
||||||
import { NounType } from '../../../src/types/graphTypes'
|
import { NounType } from '../../../src/types/graphTypes'
|
||||||
import { SparseIndex, ChunkManager } from '../../../src/utils/metadataIndexChunking'
|
import { SparseIndex, ChunkManager } from '../../../src/utils/metadataIndexChunking'
|
||||||
|
|
@ -122,10 +122,6 @@ describe('legacy sparse index: range queries order values, or refuse', () => {
|
||||||
expect(index.columnStore.hasField(FIELD)).toBe(false)
|
expect(index.columnStore.hasField(FIELD)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('(a) a long BOUND against ordinary short values', () => {
|
describe('(a) a long BOUND against ordinary short values', () => {
|
||||||
// 'apple' < 'mango' < 'zebra', and every bound below is compared against
|
// 'apple' < 'mango' < 'zebra', and every bound below is compared against
|
||||||
// these three raw keys.
|
// these three raw keys.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* validateInvariants(), and repairIndex() maps a failing invariant with heal:'rebuild'
|
* validateInvariants(), and repairIndex() maps a failing invariant with heal:'rebuild'
|
||||||
* to that provider's rebuild(). "healthy-while-broken must be impossible."
|
* to that provider's rebuild(). "healthy-while-broken must be impossible."
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType } from '../../src/index.js'
|
import { Brainy, NounType } from '../../src/index.js'
|
||||||
import type { ProviderInvariantReport } from '../../src/index.js'
|
import type { ProviderInvariantReport } from '../../src/index.js'
|
||||||
|
|
||||||
|
|
@ -48,10 +48,6 @@ describe('validateIndexConsistency delegates to provider validateInvariants() (P
|
||||||
await brain.flush()
|
await brain.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a broken provider report makes the store unhealthy and names the failing invariant', async () => {
|
it('a broken provider report makes the store unhealthy and names the failing invariant', async () => {
|
||||||
brain.index.validateInvariants = async () => brokenReport('vector')
|
brain.index.validateInvariants = async () => brokenReport('vector')
|
||||||
const v = await brain.validateIndexConsistency()
|
const v = await brain.validateIndexConsistency()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
* signal (from either strategy) THROWS VectorIndexNotReadyError immediately,
|
* signal (from either strategy) THROWS VectorIndexNotReadyError immediately,
|
||||||
* with no rebuild attempt in between — never a silent empty result.
|
* with no rebuild attempt in between — never a silent empty result.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType, VectorIndexNotReadyError } from '../../src/index.js'
|
import { Brainy, NounType, VectorIndexNotReadyError } from '../../src/index.js'
|
||||||
|
|
||||||
const V = (): number[] => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001)
|
const V = (): number[] => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001)
|
||||||
|
|
@ -28,10 +28,6 @@ describe('Vector cold-read guard (verifyVectorLive) — silent-[] on cold semant
|
||||||
await brain.flush()
|
await brain.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('warm brain: semantic find is correct and the guard does not rebuild', async () => {
|
it('warm brain: semantic find is correct and the guard does not rebuild', async () => {
|
||||||
const vi = brain.index
|
const vi = brain.index
|
||||||
let rebuilds = 0
|
let rebuilds = 0
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Tests to verify VFS import behavior and identify if VFS creates only wrappers or also graph entities
|
* Tests to verify VFS import behavior and identify if VFS creates only wrappers or also graph entities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy, NounType } from '../../src/index.js'
|
import { Brainy, NounType } from '../../src/index.js'
|
||||||
|
|
||||||
describe('VFS Multi-instance Diagnostic', () => {
|
describe('VFS Multi-instance Diagnostic', () => {
|
||||||
|
|
@ -17,10 +17,6 @@ describe('VFS Multi-instance Diagnostic', () => {
|
||||||
await brain.init()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should verify VFS creates document wrappers AND allows entity filtering', async () => {
|
it('should verify VFS creates document wrappers AND allows entity filtering', async () => {
|
||||||
console.log('\n🔬 VFS Multi-instance Diagnostic Test\n')
|
console.log('\n🔬 VFS Multi-instance Diagnostic Test\n')
|
||||||
console.log('='.repeat(70))
|
console.log('='.repeat(70))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Ensures tree methods prevent recursion and work correctly
|
* Ensures tree methods prevent recursion and work correctly
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
|
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
|
||||||
import { VFSTreeUtils } from '../../src/vfs/TreeUtils.js'
|
import { VFSTreeUtils } from '../../src/vfs/TreeUtils.js'
|
||||||
|
|
@ -24,10 +24,6 @@ describe('VFS Tree Operations', () => {
|
||||||
await vfs.init()
|
await vfs.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Critical: No Self-Inclusion Bug', () => {
|
describe('Critical: No Self-Inclusion Bug', () => {
|
||||||
it('should NEVER return a directory as its own child', async () => {
|
it('should NEVER return a directory as its own child', async () => {
|
||||||
// Create test structure
|
// Create test structure
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* - Issue #2: File read decompression error
|
* - Issue #2: File read decompression error
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
|
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
|
||||||
|
|
||||||
|
|
@ -25,10 +25,6 @@ describe('VFS Bug Fixes', () => {
|
||||||
await vfs.init()
|
await vfs.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Issue #1: Duplicate Directory Nodes', () => {
|
describe('Issue #1: Duplicate Directory Nodes', () => {
|
||||||
it('should not create duplicate directory entries when writing multiple files to same directory', async () => {
|
it('should not create duplicate directory entries when writing multiple files to same directory', async () => {
|
||||||
// Write multiple files to the same directory (reproduce the bug scenario)
|
// Write multiple files to the same directory (reproduce the bug scenario)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
* other operations in parallel batches.
|
* other operations in parallel batches.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { Brainy } from '../../src/brainy.js'
|
import { Brainy } from '../../src/brainy.js'
|
||||||
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
|
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
|
||||||
|
|
||||||
|
|
@ -30,10 +30,6 @@ describe('VFS bulkWrite Race Condition Fix', () => {
|
||||||
await vfs.init()
|
await vfs.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await brain.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('operation ordering', () => {
|
describe('operation ordering', () => {
|
||||||
it('should create directories before files when mixed in same batch', async () => {
|
it('should create directories before files when mixed in same batch', async () => {
|
||||||
// This is the exact scenario that triggered the race condition:
|
// This is the exact scenario that triggered the race condition:
|
||||||
|
|
|
||||||
Reference in a new issue