.json; this script is the one door
- * that composes an entry and lands it in the shared repo, so it is never
- * hand-written and never forked across repos again.
- *
- * Two modes:
- *
- * 1. Generate + publish (default):
- * node wall-entry.mjs --product --version --date \
- * --from-changelog
- * Derives an entry from the CHANGELOG.md entry for (headline = the
- * entry's first bullet, items = every bullet, trimmed of its trailing
- * commit hash), then:
- * - clones (or, if a cached clone already exists, fetches and resets)
- * the releases repo into a local cache directory,
- * - prepends the entry to /.json, newest first — replacing
- * any existing entry for the same version so a re-run is idempotent,
- * - validates the file's shape before and after,
- * - commits the change as "chore(wall):
" and pushes main.
- * A failure at any step (clone, validation, commit, push, a
- * non-fast-forward remote) exits non-zero naming the cure. Nothing is
- * ever skipped — the wall either lands correctly or the release fails.
- *
- * 2. Dry run:
- * node wall-entry.mjs --dry-run --product --version \
- * --date --from-changelog
- * Derives the entry exactly as above and prints it, along with the file
- * it would be written to, but touches no clone and no remote — usable
- * from a fresh checkout with no cache and no network.
- *
- * 3. Validate only (--check):
- * node wall-entry.mjs --check --file
- * Validates an arbitrary wall file's exact key set (top-level and
- * per-entry), field types, and strict-descending semver ordering with
- * no duplicates. Read-only; never writes. Exit 0 = clean, exit 1 =
- * named violations printed to stderr.
- *
- * The remote and the local cache directory are each overridable
- * (--remote / --cache-dir, or WALL_ENTRY_RELEASES_REMOTE /
- * WALL_ENTRY_RELEASES_CACHE_DIR) so tests can point at a throwaway local
- * bare repo and a throwaway cache directory — never the real remote or the
- * real developer cache.
- *
- * No dependencies beyond the system `git` binary — CHANGELOG parsing,
- * semver comparison, and JSON shape checking are all hand-rolled below.
- */
-
-import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
-import { execFileSync } from 'node:child_process'
-import { homedir } from 'node:os'
-import { dirname, join } from 'node:path'
-
-const DEFAULT_REMOTE = 'git@source.soulcraft.com:soulcraftlabs/releases.git'
-
-/** @returns {string} */
-function defaultCacheDir() {
- const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache')
- return join(base, 'soulcraft-releases')
-}
-
-// Required on every entry; "thumb" is optional (may be absent, or present as
-// string | null) — matching the HQ contract's {..., thumb?}.
-const ENTRY_REQUIRED_KEYS = ['version', 'date', 'headline', 'items', 'url']
-const ENTRY_OPTIONAL_KEYS = ['thumb']
-const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS]
-const FILE_KEYS = ['product', 'entries']
-
-// The public permalink pattern, by product. Every entry MUST carry an https
-// permalink: HQ's parser rejects a wall whose entries carry url: null (the
-// whole feed became unreadable on 2026-09-02). A product whose forge repo is
-// private links its PUBLIC package page on The Source instead of a release
-// page that would 404 for HQ's readers.
-const RELEASE_URL_PATTERNS = {
- 'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`,
- 'brainy': (version) => `https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/${version}`,
-}
-
-/**
- * Parse argv into a flag map. `--flag value` sets a string; `--flag` alone
- * (end of argv, or followed by another `--flag`) sets boolean true.
- * @param {string[]} argv
- * @returns {Record}
- */
-function parseArgs(argv) {
- /** @type {Record} */
- const args = {}
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i]
- if (!a.startsWith('--')) continue
- const key = a.slice(2)
- const next = argv[i + 1]
- if (next === undefined || next.startsWith('--')) {
- args[key] = true
- } else {
- args[key] = next
- i++
- }
- }
- return args
-}
-
-/**
- * Print a loud, named error and exit 1. Every refusal in this script goes
- * through here so the failure mode is always the same shape: "wall-entry: ".
- * @param {string} message
- * @returns {never}
- */
-function fail(message) {
- console.error(`wall-entry: ${message}`)
- process.exit(1)
-}
-
-/**
- * @param {string} version
- * @returns {{major: number, minor: number, patch: number, pre: string | null} | null}
- */
-function parseSemver(version) {
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version)
- if (!m) return null
- return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
-}
-
-/**
- * @param {string} a
- * @param {string} b
- * @returns {number} positive if a > b, negative if a < b, 0 if equal.
- */
-function compareSemver(a, b) {
- const pa = parseSemver(a)
- const pb = parseSemver(b)
- if (!pa || !pb) throw new Error(`cannot compare non-semver versions "${a}" vs "${b}"`)
- if (pa.major !== pb.major) return pa.major - pb.major
- if (pa.minor !== pb.minor) return pa.minor - pb.minor
- if (pa.patch !== pb.patch) return pa.patch - pb.patch
- if (pa.pre === pb.pre) return 0
- if (pa.pre === null) return 1 // a release outranks any prerelease of the same core version
- if (pb.pre === null) return -1
- return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0
-}
-
-/**
- * Validate a wall file's full shape: top-level keys ("product", "entries" —
- * no more, no less), per-entry keys and field types ("thumb" optional), and
- * strict-descending semver ordering with no duplicates. Collects every
- * violation instead of failing on the first, so a caller reports the whole
- * picture in one pass.
- * @param {unknown} data
- * @returns {string[]} Violation messages; empty means the file is clean.
- */
-function validateShape(data) {
- /** @type {string[]} */
- const errors = []
-
- if (typeof data !== 'object' || data === null || Array.isArray(data)) {
- return ['top level: expected a JSON object']
- }
- const obj = /** @type {Record} */ (data)
-
- const topKeys = Object.keys(obj)
- const missingTop = FILE_KEYS.filter((k) => !(k in obj))
- const extraTop = topKeys.filter((k) => !FILE_KEYS.includes(k))
- if (missingTop.length) errors.push(`top level: missing key(s) ${missingTop.join(', ')}`)
- if (extraTop.length) errors.push(`top level: unexpected key(s) ${extraTop.join(', ')}`)
-
- if (typeof obj.product !== 'string' || obj.product.trim() === '') {
- errors.push('top level: "product" must be a non-empty string')
- }
- if (!Array.isArray(obj.entries)) {
- errors.push('top level: "entries" must be an array')
- return errors // nothing further to check without an array
- }
-
- const entries = /** @type {unknown[]} */ (obj.entries)
- entries.forEach((rawEntry, i) => {
- const label = `entries[${i}]`
- if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) {
- errors.push(`${label}: expected an object`)
- return
- }
- const entry = /** @type {Record} */ (rawEntry)
- const keys = Object.keys(entry)
- const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry))
- const extra = keys.filter((k) => !ENTRY_ALLOWED_KEYS.includes(k))
- if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`)
- if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`)
-
- if (typeof entry.version !== 'string' || !parseSemver(entry.version)) {
- errors.push(`${label}: "version" must be a semver string (got ${JSON.stringify(entry.version)})`)
- }
- if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(entry.date))) {
- errors.push(`${label}: "date" must be a YYYY-MM-DD string (got ${JSON.stringify(entry.date)})`)
- }
- if (typeof entry.headline !== 'string' || entry.headline.trim() === '') {
- errors.push(`${label}: "headline" must be a non-empty string`)
- }
- if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) {
- errors.push(`${label}: "items" must be a non-empty array of non-empty strings`)
- }
- if (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) {
- errors.push(`${label}: "url" must be an https permalink — never null; HQ's parser rejects the whole feed`)
- }
- if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) {
- errors.push(`${label}: "thumb" must be a string or null when present`)
- }
- })
-
- // Ordering: newest first, strictly descending, no duplicate versions —
- // checked only over entries whose version parsed (a bad version is
- // already reported above; comparing it too would just be noise).
- const versioned = entries
- .map((e, i) => ({ i, version: /** @type {any} */ (e)?.version }))
- .filter((e) => typeof e.version === 'string' && parseSemver(e.version))
- for (let i = 0; i < versioned.length - 1; i++) {
- const a = versioned[i]
- const b = versioned[i + 1]
- const cmp = compareSemver(a.version, b.version)
- if (cmp === 0) {
- errors.push(`entries[${a.i}] and entries[${b.i}]: duplicate version ${a.version}`)
- } else if (cmp < 0) {
- errors.push(`entries[${a.i}] (${a.version}) sits above entries[${b.i}] (${b.version}) — not newest-first`)
- }
- }
-
- return errors
-}
-
-/**
- * Extract one version's entry body from a standard-version-style CHANGELOG.md
- * (headings `### [version](url) (date)`, followed by `- bullet (hash)` lines
- * until the next heading or EOF).
- * @param {string} changelog
- * @param {string} version
- * @returns {string[]} Bullet lines, trimmed of their leading "- " and
- * trailing " (hash)".
- */
-function extractChangelogBullets(changelog, version) {
- const lines = changelog.split('\n')
- const headingRe = /^### \[([^\]]+)\]\(.*\)\s*\(\d{4}-\d{2}-\d{2}\)\s*$/
- let start = -1
- for (let i = 0; i < lines.length; i++) {
- const m = headingRe.exec(lines[i])
- if (m && m[1] === version) {
- start = i + 1
- break
- }
- }
- if (start === -1) {
- fail(
- `version ${version} has no CHANGELOG entry yet — run this after the CHANGELOG step composes "### [${version}]", not before`,
- )
- }
- /** @type {string[]} */
- const bullets = []
- for (let i = start; i < lines.length; i++) {
- if (headingRe.test(lines[i])) break // next entry starts
- const bulletMatch = /^- (.+?)(?:\s\(([0-9a-f]{6,40})\))?$/.exec(lines[i].trim())
- if (lines[i].trim().startsWith('- ') && bulletMatch) {
- const text = bulletMatch[1].trim()
- if (text) bullets.push(text)
- }
- }
- if (bullets.length === 0) {
- fail(`version ${version}'s CHANGELOG entry has no bullets to derive a headline/items from`)
- }
- return bullets
-}
-
-/**
- * Derive a wall entry from a CHANGELOG.md.
- * @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts
- * @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}}
- */
-function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
- if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`)
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
- fail(`--date "${date}" is not a YYYY-MM-DD date`)
- }
- if (!existsSync(changelogPath)) fail(`--from-changelog "${changelogPath}" does not exist`)
-
- const changelog = readFileSync(changelogPath, 'utf8')
- const items = extractChangelogBullets(changelog, version)
- const headline = items[0]
-
- const pattern = RELEASE_URL_PATTERNS[product]
- if (url === undefined && pattern === undefined) {
- throw new Error(`wall-entry: no permalink pattern for product "${product}" — add one to RELEASE_URL_PATTERNS or pass --url; entries never carry url: null`)
- }
- const resolvedUrl = url !== undefined ? url : pattern(version)
- const resolvedThumb = thumb !== undefined ? thumb : null
-
- return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb }
-}
-
-/**
- * Load and shape-validate a wall file.
- * @param {string} filePath
- * @returns {Record}
- */
-function loadWallFile(filePath) {
- if (!existsSync(filePath)) fail(`"${filePath}" does not exist`)
- /** @type {unknown} */
- let data
- try {
- data = JSON.parse(readFileSync(filePath, 'utf8'))
- } catch (err) {
- fail(`"${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`)
- }
- const errors = validateShape(data)
- if (errors.length) {
- fail(`"${filePath}" fails shape validation —\n ${errors.join('\n ')}`)
- }
- return /** @type {Record} */ (data)
-}
-
-/**
- * Run a git command, throwing an Error whose message is git's own stderr
- * (trimmed) on failure — every caller wraps this to name the cure.
- * @param {string[]} args
- * @param {string} cwd
- * @returns {string} stdout, trimmed.
- */
-function git(args, cwd) {
- try {
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
- } catch (err) {
- const stderr = /** @type {any} */ (err).stderr
- const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message
- throw new Error(message)
- }
-}
-
-/**
- * Ensure a clean, up-to-date local clone of the releases repo at
- * `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no
- * `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a
- * stray local commit or edit left by a previous failed run can never leak
- * into the next one).
- * @param {string} remote
- * @param {string} cacheDir
- */
-function ensureReleasesClone(remote, cacheDir) {
- if (existsSync(join(cacheDir, '.git'))) {
- try {
- git(['remote', 'set-url', 'origin', remote], cacheDir)
- git(['fetch', '--prune', 'origin'], cacheDir)
- git(['checkout', 'main'], cacheDir)
- git(['reset', '--hard', 'origin/main'], cacheDir)
- git(['clean', '-fd'], cacheDir)
- } catch (err) {
- fail(
- `cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" — ${/** @type {Error} */ (err).message}\n` +
- ` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`,
- )
- }
- return
- }
-
- mkdirSync(dirname(cacheDir), { recursive: true })
- try {
- git(['clone', remote, cacheDir], dirname(cacheDir))
- } catch (err) {
- fail(
- `cannot clone "${remote}" — ${/** @type {Error} */ (err).message}\n` +
- ` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`,
- )
- }
- try {
- git(['checkout', 'main'], cacheDir)
- } catch (err) {
- fail(
- `cloned "${remote}" into "${cacheDir}" but could not check out "main" — ${/** @type {Error} */ (err).message}\n` +
- ` cure: confirm the releases repo's default branch is named "main"`,
- )
- }
-}
-
-/**
- * Prepend `entry` to the wall at `/.json`, replacing any
- * existing entry for the same version (idempotent re-runs), validating
- * before and after, committing, and pushing — or refusing loudly, naming
- * the cure, at whichever step fails.
- * @param {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} entry
- * @param {string} product
- * @param {string} remote
- * @param {string} cacheDir
- */
-function publishEntry(entry, product, remote, cacheDir) {
- ensureReleasesClone(remote, cacheDir)
-
- const filePath = join(cacheDir, `${product}.json`)
- if (!existsSync(filePath)) {
- fail(
- `"${filePath}" does not exist in the releases repo — cure: seed "${product}.json" at the repo root first (it must exist before any release rail can prepend to it)`,
- )
- }
- const wall = loadWallFile(filePath)
-
- if (wall.product !== product) {
- fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given — refusing a cross-product write`)
- }
-
- const replacing = wall.entries.some((e) => e.version === entry.version)
- wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)]
-
- const postErrors = validateShape(wall)
- if (postErrors.length) {
- fail(`the entry for ${entry.version} would leave "${filePath}" invalid —\n ${postErrors.join('\n ')}`)
- }
-
- writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8')
-
- const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir)
- if (status === '') {
- console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} — nothing to commit or push`)
- return
- }
-
- try {
- git(['add', `${product}.json`], cacheDir)
- git(['commit', '-m', `chore(wall): ${product} ${entry.version}`], cacheDir)
- } catch (err) {
- fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`)
- }
-
- try {
- git(['push', 'origin', 'main'], cacheDir)
- } catch (err) {
- fail(
- `push to "${remote}" failed (likely a non-fast-forward — another release landed on main first) — ${/** @type {Error} */ (err).message}\n` +
- ` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`,
- )
- }
-
- const sha = git(['rev-parse', 'HEAD'], cacheDir)
- console.log(
- `wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) — pushed ${sha} to ${remote} main`,
- )
-}
-
-function main() {
- const args = parseArgs(process.argv.slice(2))
-
- if (args.check) {
- const filePath = /** @type {string | undefined} */ (args.file)
- if (!filePath) fail('--check needs --file ')
- const wall = loadWallFile(/** @type {string} */ (filePath))
- console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`)
- process.exit(0)
- }
-
- // Generate mode (default, also covers --dry-run): --product, --version,
- // --date, --from-changelog required.
- const product = /** @type {string | undefined} */ (args.product)
- const version = /** @type {string | undefined} */ (args.version)
- const date = /** @type {string | undefined} */ (args.date)
- const fromChangelog = /** @type {string | undefined} */ (args['from-changelog'])
-
- const missing = []
- if (!product) missing.push('--product')
- if (!version) missing.push('--version')
- if (!date) missing.push('--date')
- if (!fromChangelog) missing.push('--from-changelog')
- if (missing.length) {
- fail(
- `missing required flag(s): ${missing.join(', ')}\n` +
- 'Usage:\n' +
- ' wall-entry.mjs --product --version --date --from-changelog [--dry-run]\n' +
- ' wall-entry.mjs --check --file ',
- )
- }
-
- const urlArg = args.url === true ? undefined : /** @type {string | undefined} */ (args.url)
- const thumbArg = args.thumb === true ? undefined : /** @type {string | undefined} */ (args.thumb)
-
- const entry = deriveEntry({
- product: /** @type {string} */ (product),
- version: /** @type {string} */ (version),
- date: /** @type {string} */ (date),
- changelogPath: /** @type {string} */ (fromChangelog),
- url: urlArg,
- thumb: thumbArg,
- })
-
- const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE)
- const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir())
-
- if (args['dry-run']) {
- console.log(`wall-entry --dry-run: would write to "${join(cacheDir, `${product}.json`)}" in ${remote} (main), pushed as "chore(wall): ${product} ${version}"`)
- console.log(JSON.stringify(entry, null, 2))
- process.exit(0)
- }
-
- publishEntry(entry, /** @type {string} */ (product), remote, cacheDir)
-}
-
-main()
diff --git a/src/aggregation/AggregationIndex.ts b/src/aggregation/AggregationIndex.ts
index d3a1fd74..7f7ffb27 100644
--- a/src/aggregation/AggregationIndex.ts
+++ b/src/aggregation/AggregationIndex.ts
@@ -14,22 +14,7 @@
*/
import type { StorageAdapter, HNSWNounWithMetadata } from '../coreTypes.js'
-import { parseFieldAddress, readEntityFieldAddress } from '../db/fieldAddressing.js'
-import type { HNSWNounWithMetadata as AddressedEntity } from '../coreTypes.js'
-
-/**
- * Read a user-supplied field name under the one addressing law (sealed
- * 2026-08-03): bare / `metadata.` = the user's metadata field, `system.` =
- * the ruled engine scalar, malformed = typed refusal. The aggregation engine
- * NEVER resolves names any other way — the pre-law resolver made bare
- * `subtype`/`confidence` read engine scalars, silently shadowing user fields.
- */
-function readAddressed(e: unknown, name: string): unknown {
- return readEntityFieldAddress(
- e as AddressedEntity,
- parseFieldAddress(name, 'entity')
- )
-}
+import { resolveEntityField } from '../coreTypes.js'
import type {
AggregateDefinition,
AggregateGroupState,
@@ -103,22 +88,10 @@ function matchesSource(entity: Record, source: AggregateDefinit
if (entity.service !== source.service) return false
}
- // Where filter — resolve each filtered field through resolveEntityField,
- // the SAME single source of truth groupBy uses (top-level standard fields
- // + custom metadata). Matching only the metadata sub-object made
- // where:{subtype}/{visibility}/… a silent no-op: reserved fields never
- // live in the custom bag, so those filters could never match anything.
+ // Metadata where filter — match against the entity's metadata sub-object
if (source.where && Object.keys(source.where).length > 0) {
- const e = entity as unknown as HNSWNounWithMetadata
- for (const [key, condition] of Object.entries(source.where)) {
- // Evaluate ONE field at a time under a neutral key: the address may be
- // dotted ('system.subtype'), and the filter evaluator would otherwise
- // walk dots as a nested path instead of treating the key as an address.
- const value = readAddressed(e, key)
- if (!matchesMetadataFilter({ v: value }, { v: condition } as Record)) {
- return false
- }
- }
+ const metadata = (entity.metadata ?? entity) as Record
+ if (!matchesMetadataFilter(metadata, source.where)) return false
}
return true
@@ -148,11 +121,11 @@ function computeGroupKeys(
for (const dim of groupBy) {
if (typeof dim === 'string') {
- const val = readAddressed(e, dim)
+ const val = resolveEntityField(e, dim)
const v = val !== undefined && val !== null ? String(val) : '__null__'
for (const k of keys) k[dim] = v
} else if ('unnest' in dim) {
- const val = readAddressed(e, dim.field)
+ const val = resolveEntityField(e, dim.field)
const raw = Array.isArray(val) ? val : val !== undefined && val !== null ? [val] : []
// Distinct elements: an entity with duplicate tags counts once per distinct tag.
const elems = Array.from(new Set(raw.map(x => String(x))))
@@ -164,7 +137,7 @@ function computeGroupKeys(
keys = next
} else {
// Time-windowed field
- const val = readAddressed(e, dim.field)
+ const val = resolveEntityField(e, dim.field)
const v = typeof val === 'number' ? bucketTimestamp(val, dim.window) : '__null__'
for (const k of keys) k[dim.field] = v
}
@@ -193,7 +166,7 @@ function computeGroupKey(
* in metadata are both handled in one place.
*/
function getNumericField(entity: Record, field: string): number | undefined {
- const val = readAddressed(entity as unknown as HNSWNounWithMetadata, field)
+ const val = resolveEntityField(entity as unknown as HNSWNounWithMetadata, field)
if (typeof val === 'number' && !isNaN(val)) return val
if (typeof val === 'string') {
const num = parseFloat(val)
@@ -371,15 +344,6 @@ export class AggregationIndex {
*/
private pendingAdopt = new Set()
- /**
- * Aggregates adopted with a BEHIND stamp: name → the exact generation
- * window `(from, to]` whose writes the adopted state has not seen. The
- * owner (Brainy) drains this via {@link getPendingCatchUps} +
- * {@link reconcileEntity} + {@link finishCatchUp} BEFORE serving queries —
- * cost bounded by the window's affected entities, never store size.
- */
- private pendingCatchUp = new Map()
-
/**
* In-flight rescan targets. While a name has a staging map, ALL
* contributions (the walk's and concurrent write hooks') land there instead
@@ -446,47 +410,25 @@ export class AggregationIndex {
}
/**
- * The adoption verdict for persisted state, against the store's committed
- * watermark (SELF-ENGINE-LIFECYCLE-SPRINT ask (b) — behind-stamp is no
- * longer a whole-store rescan):
- *
- * - `'adopt'` — stamp equals the watermark (clean), or the store has no
- * watermark capability (hash-only adoption, the pre-stamp behavior).
- * - `'catchup'` — stamp is BEHIND the watermark (an unclean exit after
- * later writes, or a long-lived writer whose last flush predates recent
- * writes). The state is exact AS OF its stamp, so it is adopted and the
- * missing window `(stamp, committed]` is reconciled INCREMENTALLY per
- * affected entity via time-travel reads — bounded by writes since the
- * last flush, never by store size. The owner drains
- * {@link getPendingCatchUps} before serving queries.
- * - `'rescan'` — no stamp (pre-stamp state on a stamped store) or stamp
- * AHEAD of the watermark (e.g. a fact-log truncation on a copied store
- * pulled the watermark back): the state over-counts unverifiably; one
- * exact rescan, said out loud.
+ * May this persisted state be ADOPTED? When the store exposes its committed
+ * watermark, the state's `sourceGeneration` must EQUAL it: behind means
+ * later writes are missing from the state (unclean shutdown); ahead means
+ * it counts writes that no longer exist (e.g. a fact-log truncation on a
+ * copied store pulled the watermark back). Either way: one exact rescan,
+ * said out loud — never a silent adopt. Stores without the capability (and
+ * pre-stamp state on them) fall back to hash-only adoption.
*/
- private stateAdoptionVerdict(
- name: string,
- stateData: unknown
- ): 'adopt' | 'catchup' | 'rescan' {
+ private stateGenerationAdoptable(name: string, stateData: unknown): boolean {
const committed = this.storage.committedGeneration?.() ?? null
- if (committed === null) return 'adopt'
+ if (committed === null) return true
const raw = (stateData as Record).sourceGeneration
const stamped = typeof raw === 'number' ? raw : null
- if (stamped === committed) return 'adopt'
- if (stamped !== null && stamped < committed) {
- this.pendingCatchUp.set(name, { from: stamped, to: committed })
- prodLog.info(
- `[Aggregation] '${name}': persisted state is at generation ${stamped}, store is at ` +
- `${committed} — adopting and reconciling the ${committed - stamped}-generation window ` +
- `incrementally (no store rescan)`
- )
- return 'catchup'
- }
+ if (stamped === committed) return true
prodLog.warn(
`[Aggregation] '${name}': persisted state is at generation ${stamped ?? 'unstamped'} ` +
`but the store's committed generation is ${committed} — rescanning instead of adopting`
)
- return 'rescan'
+ return false
}
private async loadPersisted(): Promise {
@@ -507,21 +449,20 @@ export class AggregationIndex {
const appHash = this.definitionHashes.get(def.name) || ''
if (appHash === savedHash && this.pendingAdopt.has(def.name)) {
const stateData = await this.storage.getMetadata(`${STATE_KEY_PREFIX}${def.name}__`)
- const verdict =
- stateData && stateData.groups
- ? this.stateAdoptionVerdict(def.name, stateData)
- : 'rescan'
- if (verdict !== 'rescan') {
+ if (
+ stateData &&
+ stateData.groups &&
+ this.stateGenerationAdoptable(def.name, stateData)
+ ) {
const groupMap = new Map()
- for (const group of stateData!.groups as AggregateGroupState[]) {
+ for (const group of stateData.groups as AggregateGroupState[]) {
groupMap.set(serializeGroupKey(group.groupKey), group)
}
this.states.set(def.name, groupMap)
this.pendingAdopt.delete(def.name)
this.needsBackfill.delete(def.name)
prodLog.info(
- `[Aggregation] '${def.name}': adopted persisted state (${groupMap.size} groups) — ` +
- (verdict === 'catchup' ? 'incremental catch-up pending' : 'no rescan')
+ `[Aggregation] '${def.name}': adopted persisted state (${groupMap.size} groups) — no rescan`
)
}
// No/invalid persisted state: stays in pendingAdopt and resolves
@@ -536,23 +477,22 @@ export class AggregationIndex {
const currentHash = hashDefinition(def)
const stateData = await this.storage.getMetadata(`${STATE_KEY_PREFIX}${def.name}__`)
- const restoreVerdict =
- stateData && stateData.groups && savedHash === currentHash
- ? this.stateAdoptionVerdict(def.name, stateData)
- : 'rescan'
- if (restoreVerdict !== 'rescan') {
- // Definition unchanged — load state (exact as of its stamp; a
- // 'catchup' verdict reconciles the missing window incrementally).
+ if (
+ stateData &&
+ stateData.groups &&
+ savedHash === currentHash &&
+ this.stateGenerationAdoptable(def.name, stateData)
+ ) {
+ // Definition unchanged — load state
const groupMap = new Map()
- for (const group of stateData!.groups as AggregateGroupState[]) {
+ for (const group of stateData.groups as AggregateGroupState[]) {
const serialized = serializeGroupKey(group.groupKey)
groupMap.set(serialized, group)
}
this.states.set(def.name, groupMap)
this.needsBackfill.delete(def.name)
prodLog.info(
- `[Aggregation] '${def.name}': restored definition + adopted persisted state (${groupMap.size} groups)` +
- (restoreVerdict === 'catchup' ? ' — incremental catch-up pending' : '')
+ `[Aggregation] '${def.name}': restored definition + adopted persisted state (${groupMap.size} groups)`
)
} else {
// Definition changed or no saved state — start fresh and backfill from
@@ -570,35 +510,15 @@ export class AggregationIndex {
}
}
- // Restore native provider state from persistence — GATED by the same
- // adoption verdict as caller-side state (the unconditional adopt was an
- // asymmetry: a stale native blob restored over a moved store silently
- // over/under-counted). 'adopt' restores; 'catchup' restores too (the
- // incremental reconciliation drives the provider through
- // incrementalUpdate over the exact missing window); 'rescan' SKIPS the
- // blob — the flagged rebuild repopulates the provider from source.
- // Legacy unstamped envelopes verdict as rescan, loudly, never silently.
+ // Restore native provider state from persistence
if (this.nativeProvider?.restoreState) {
const nativeState = await this.storage.getMetadata('__aggregation_native_state__')
- const blob =
- nativeState && typeof nativeState === 'string'
- ? nativeState
- : nativeState && typeof nativeState === 'object' && nativeState.data
- ? (nativeState.data as string)
- : null
- if (blob !== null) {
- const verdict = this.stateAdoptionVerdict(
- '__native__',
- nativeState && typeof nativeState === 'object' ? (nativeState as Record) : {}
- )
- if (verdict === 'adopt' || verdict === 'catchup') {
- this.nativeProvider.restoreState(blob)
- } else {
- prodLog.warn(
- `[Aggregation] native provider state not adopted (verdict: ${verdict}) — ` +
- `the flagged rescan repopulates the provider from source`
- )
- }
+ if (nativeState && typeof nativeState === 'string') {
+ this.nativeProvider.restoreState(nativeState)
+ } else if (nativeState && typeof nativeState === 'object' && nativeState.data) {
+ // flush() persists `{ data: serializeState() }`, so `data` is the
+ // provider's serialized state string.
+ this.nativeProvider.restoreState(nativeState.data as string)
}
}
}
@@ -634,17 +554,12 @@ export class AggregationIndex {
}
}
- // Persist native provider state — stamped. noteSourceGeneration lets the
- // provider bake the committed watermark into its OWN envelope before
- // serializing (so a native-side reopen can verify honesty without our
- // wrapper); the wrapper carries the same stamp for OUR adoption verdict.
+ // Persist native provider state
if (this.nativeProvider?.serializeState) {
- const nativeGen = this.storage.committedGeneration?.() ?? null
- if (nativeGen !== null) this.nativeProvider.noteSourceGeneration?.(nativeGen)
const nativeState = this.nativeProvider.serializeState()
await this.storage.saveMetadata(
'__aggregation_native_state__',
- nativeGen === null ? { data: nativeState } : { data: nativeState, sourceGeneration: nativeGen }
+ { data: nativeState }
)
}
@@ -805,119 +720,6 @@ export class AggregationIndex {
this.dirty.add(name)
}
- // ============= Incremental Catch-Up (behind-stamp adoption) =============
-
- /** The aggregates adopted behind the watermark, with their exact missing windows. */
- getPendingCatchUps(): Array<{ name: string; from: number; to: number }> {
- return Array.from(this.pendingCatchUp, ([name, w]) => ({ name, ...w }))
- }
-
- /**
- * Reconcile ONE entity's contribution across a catch-up window using the
- * same exact delta algebra the write-time hooks use: remove the
- * contribution the adopted state counted (the entity AS OF the stamp),
- * add the contribution it should count (AS OF the window's end). `null`
- * on either side means the entity did not exist then. Composes exactly
- * with live hooks because every application is a precise old/new pair —
- * order between catch-up and post-window writes cannot drift the totals.
- */
- reconcileEntity(
- name: string,
- id: string,
- before: Record | null,
- after: Record | null
- ): void {
- const def = this.definitions.get(name)
- if (!def) return
- if (before && after) {
- if (isAggregateEntity(after)) return
- const oldMatches = matchesSource(before, def.source)
- const newMatches = matchesSource(after, def.source)
- if (this.nativeProvider && (oldMatches || newMatches)) {
- this.applyNativeResults(
- name,
- this.nativeProvider.incrementalUpdate(name, def, after, 'update', before)
- )
- return
- }
- if (oldMatches) this.removeContribution(name, def, before)
- if (newMatches) this.addContribution(name, def, after)
- return
- }
- if (after) {
- if (isAggregateEntity(after) || !matchesSource(after, def.source)) return
- if (this.nativeProvider) {
- this.applyNativeResults(name, this.nativeProvider.incrementalUpdate(name, def, after, 'add'))
- } else {
- this.addContribution(name, def, after)
- }
- return
- }
- if (before) {
- if (isAggregateEntity(before) || !matchesSource(before, def.source)) return
- if (this.nativeProvider) {
- this.applyNativeResults(name, this.nativeProvider.incrementalUpdate(name, def, before, 'delete'))
- } else {
- this.removeContribution(name, def, before)
- }
- }
- }
-
- /** Whether the native provider offers the parallel whole-rebuild path. */
- hasProviderRebuild(): boolean {
- return typeof this.nativeProvider?.rebuildAggregate === 'function'
- }
-
- /** The catch-up window for `name` is fully reconciled; state is current. */
- finishCatchUp(name: string): void {
- this.pendingCatchUp.delete(name)
- this.dirty.add(name)
- }
-
- /**
- * A catch-up could not complete (window unreadable, affected set over the
- * bound, …): demote to an exact rescan, loudly — never serve un-reconciled.
- */
- demoteCatchUpToBackfill(name: string, reason: string): void {
- this.pendingCatchUp.delete(name)
- this.needsBackfill.add(name)
- prodLog.warn(`[Aggregation] '${name}': catch-up demoted to full rescan — ${reason}`)
- }
-
- /**
- * Rebuild an aggregate through the native provider's parallel path
- * (SELF-ENGINE-LIFECYCLE-SPRINT ask (c) — `rebuildAggregate` existed on
- * the provider contract but was never invoked; the JS walk fed
- * per-entity FFI calls instead). Returns false when no provider rebuild
- * exists — the caller streams the JS walk as before.
- */
- rebuildWithProvider(name: string, entities: Array>): boolean {
- const def = this.definitions.get(name)
- if (!def || !this.nativeProvider?.rebuildAggregate) return false
- const rebuilt = this.nativeProvider.rebuildAggregate(
- def,
- entities.filter(e => !isAggregateEntity(e) && matchesSource(e, def.source))
- )
- this.states.set(name, rebuilt)
- this.backfillStaging.delete(name)
- this.needsBackfill.delete(name)
- this.dirty.add(name)
- return true
- }
-
- /**
- * A write-path hook could not see the entity it needed (e.g. a delete
- * whose before-image was unavailable): flag EVERY defined aggregate for
- * an exact rescan, loudly — the counts must never silently drift
- * (SELF-ENGINE-LIFECYCLE-SPRINT ask (d): the gated hook used to SKIP).
- */
- flagAllForRescan(reason: string): void {
- for (const name of this.definitions.keys()) this.needsBackfill.add(name)
- prodLog.warn(
- `[Aggregation] all ${this.definitions.size} aggregate(s) flagged for rescan — ${reason}`
- )
- }
-
// ============= Write-Time Hooks =============
/**
@@ -1180,7 +982,7 @@ export class AggregationIndex {
// distinctCount tracks distinct values of ANY type (strings, numbers, booleans),
// keyed by their string form — NOT numeric-coerced, since its primary use is
// categorical (distinct categories / users / tags), not numeric columns.
- const raw = readAddressed(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
+ const raw = resolveEntityField(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
if (raw !== undefined && raw !== null) {
if (!state.valueCounts) state.valueCounts = {}
const key = String(raw)
@@ -1224,7 +1026,7 @@ export class AggregationIndex {
state.count = Math.max(0, state.count - 1)
state.sum = Math.max(0, state.sum - 1)
} else if (metricDef.op === 'distinctCount') {
- const raw = readAddressed(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
+ const raw = resolveEntityField(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
if (raw !== undefined && raw !== null && state.valueCounts) {
const key = String(raw)
const c = state.valueCounts[key]
diff --git a/src/brainy.ts b/src/brainy.ts
index b8eb7f56..008733b9 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -15,7 +15,6 @@ import { JsHnswVectorIndex } from './hnsw/hnswIndex.js'
import { createStorage, resolveFilesystemRoot } from './storage/storageFactory.js'
import type { StorageOptions } from './storage/storageFactory.js'
import { rebuildCounts } from './utils/rebuildCounts.js'
-import { jsonSafeIndexMetadata } from './utils/jsonSafeIndexMetadata.js'
import type { MetadataWriteBuffer } from './utils/metadataWriteBuffer.js'
import { BaseStorage } from './storage/baseStorage.js'
import {
@@ -26,8 +25,7 @@ import {
} from './storage/brainFormat.js'
import type { BrainFormat } from './storage/brainFormat.js'
import { StorageAdapter, Vector, DistanceFunction, EmbeddingFunction, GraphVerb, STANDARD_ENTITY_FIELDS } from './coreTypes.js'
-import { isZeroNormVector } from './utils/distance.js'
-import type { HNSWNoun, HNSWNounWithMetadata, HNSWVerbWithMetadata, EntityVisibility } from './coreTypes.js'
+import type { HNSWNounWithMetadata, HNSWVerbWithMetadata, EntityVisibility } from './coreTypes.js'
import {
defaultEmbeddingFunction,
cosineDistance,
@@ -65,10 +63,7 @@ import type {
PathOptions,
MetadataIndexProvider,
OpaqueIdSet,
- AtGenerationVectors,
- VectorIndexProvider,
- GraphIndexProvider,
- ProviderMaintenanceDebt
+ AtGenerationVectors
} from './plugin.js'
import type {
BrainyPlugin,
@@ -93,13 +88,12 @@ import { findCallerLocation } from './utils/callerLocation.js'
import {
SaveNounMetadataOperation,
SaveNounOperation,
- AddToVectorIndexOperation,
+ AddToHNSWOperation,
AddToMetadataIndexOperation,
SaveVerbMetadataOperation,
SaveVerbOperation,
AddToGraphIndexOperation,
- RemoveFromVectorIndexOperation,
- ReplaceInVectorIndexOperation,
+ RemoveFromHNSWOperation,
RemoveFromMetadataIndexOperation,
RemoveFromGraphIndexOperation,
UpdateNounMetadataOperation,
@@ -146,16 +140,12 @@ import {
ScoreExplanation,
FillSubtypeRule,
FillSubtypeRules,
- FillSubtypesResult,
- RepairReport,
- RepairFamilyReport
+ FillSubtypesResult
} from './types/brainy.types.js'
import { NounType, VerbType, TypeUtils } from './types/graphTypes.js'
import {
splitNounMetadataRecord,
- splitVerbMetadataRecord,
- buildNounMetadataRecord,
- buildVerbMetadataRecord
+ splitVerbMetadataRecord
} from './types/reservedFields.js'
import { BrainyInterface } from './types/brainyInterface.js'
import type { IntegrationHub, IntegrationHubConfig } from './integrations/core/IntegrationHub.js'
@@ -165,8 +155,6 @@ import { AggregationIndex } from './aggregation/AggregationIndex.js'
import { AggregateMaterializer } from './aggregation/materializer.js'
import type { AggregateDefinition, AggregateQueryParams, AggregateResult } from './types/brainy.types.js'
import type { MigrationProgress } from './types/brainy.types.js'
-import type { IndexedProjectionPath, WaitForIndexedOptions } from './types/brainy.types.js'
-import { WaitForIndexedTimeoutError } from './types/brainy.types.js'
import { resolveJsHnswConfig, DEFAULT_RECALL } from './utils/recallPreset.js'
import * as fs from 'node:fs'
import * as os from 'node:os'
@@ -182,7 +170,7 @@ import {
type ImportResult
} from './db/portableGraph.js'
import { GenerationStore, type CommitBeforeImages } from './db/generationStore.js'
-import type { FactScanHandle, FactMarkerRecord } from './db/factLog.js'
+import type { FactScanHandle } from './db/factLog.js'
import {
ENTITY_TREE_STAMP_PATH,
readFamilyStamp,
@@ -199,25 +187,7 @@ import {
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
import { GenerationConflictError, StoreInconsistentError } from './db/errors.js'
import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError, VectorIndexNotReadyError } from './errors/brainyError.js'
-import {
- assessIndexReadiness,
- assessProviderHealth,
- assessProviderRebuild,
- describeRebuildProgress
-} from './utils/indexReadiness.js'
-import { reconstructNounWrapper } from './db/factLog.js'
-import { asBrainyFieldRefusal } from './db/fieldAddressing.js'
-import {
- readLogAuthority,
- runLogCompletenessOracle,
- flipToLogAuthority,
- recordDigest,
- nounEntityTruth,
- LOG_AUTHORITY_PATH,
- type LogAuthorityRecord,
- type LogAuthorityStorage,
- type OracleReport
-} from './db/logAuthority.js'
+import { assessIndexReadiness } from './utils/indexReadiness.js'
import { MemoryStorage } from './storage/adapters/memoryStorage.js'
import type {
CompactHistoryOptions,
@@ -296,8 +266,6 @@ type ResolvedBrainyConfig = Required<
| 'retention'
| 'eagerEmbeddings'
| 'migrationWaitTimeoutMs'
- | 'transactionBudgetFloorMs'
- | 'persistence'
>
> &
Pick<
@@ -310,8 +278,6 @@ type ResolvedBrainyConfig = Required<
| 'retention'
| 'eagerEmbeddings'
| 'migrationWaitTimeoutMs'
- | 'transactionBudgetFloorMs'
- | 'persistence'
>
/**
@@ -395,22 +361,6 @@ interface PlannedTransact {
* rejected batch (CAS conflict, failed apply) emits nothing.
*/
changeEvents: PendingChangeEvent[]
- /**
- * V2 marker records riding the batch's ONE commit fact (e.g. the
- * deferred-embedding pending markers) — same generation, same atomic
- * append as the batch itself. A rejected batch appends no fact, so no
- * marker outlives its write.
- */
- markerRecords: FactMarkerRecord[]
- /**
- * Ids the batch's `{ op: 'update' }` unvector door (`vector: []`) needs to
- * decrement on the vectored-noun ledger — consumed by `transact()` with a
- * proper `await this.storage.noteVectorUnlanded?.(id)` per id, AFTER
- * `commitTransaction` resolves (never for a rejected batch). Kept separate
- * from `postCommit` (`Array<() => void>`, called synchronously, fire-and-
- * forget) because the ledger hook is async and must be awaited.
- */
- vectorUnlands: string[]
}
/**
@@ -438,62 +388,6 @@ class InsertPreconditionExistsSignal extends Error {
*/
export type IndexFamily = 'vector' | 'metadata' | 'graph'
-/**
- * @description Honest per-surface outcome for {@link Brainy.warm}. Literal
- * meanings — never conflate the first two:
- * - `'warmed'` — the provider's own `warm?()` hook ran (vector/graph), or the
- * surface's full-hydration seam loaded EVERY shard/field/segment from
- * storage (metadata; graph's fallback path). The surface is genuinely at
- * steady-state cost for the next operation.
- * - `'probed'` — no `warm?()` hook was available, so a best-effort read
- * (e.g. one `search()` call) faulted in *some* backing storage as a side
- * effect. Real work happened, but it is NOT the same guarantee as
- * `'warmed'` — never reported as `'warmed'`.
- * - `'unavailable'` — nothing ran: no hook, no hydration seam, and (for the
- * vector probe fallback) nothing to probe (an empty index or unknown
- * vector dimension). The surface is unchanged by this `warm()` call.
- */
-export type WarmOutcome = 'warmed' | 'probed' | 'unavailable'
-
-/**
- * @description Result of {@link Brainy.warm}: one {@link WarmOutcome} +
- * elapsed time per index surface, plus the total wall-clock time for the
- * whole call. `durationMs` is measured around exactly the work described by
- * that surface's `outcome` (e.g. the vector entry's `durationMs` times the
- * provider `warm()` call OR the probe `search()` call — whichever ran).
- */
-export interface WarmReport {
- vector: { outcome: WarmOutcome; durationMs: number }
- metadata: { outcome: WarmOutcome; durationMs: number }
- graph: { outcome: WarmOutcome; durationMs: number }
- /** Total wall-clock time for the whole `warm()` call (all three surfaces). */
- totalDurationMs: number
-}
-
-/**
- * @description Result of {@link Brainy.maintenanceDebt}: one outcome per
- * index surface, mirroring {@link WarmReport}'s shape.
- * - `'reported'` — the active provider for this surface implements
- * `maintenanceDebt?()` and its {@link ProviderMaintenanceDebt} payload is
- * attached verbatim under `debt`.
- * - `'unavailable'` — the active provider does not implement the hook, so
- * nothing is known; brainy never estimates or infers a payload on its
- * behalf.
- */
-export type MaintenanceDebtOutcome = 'reported' | 'unavailable'
-
-/**
- * @description Per-surface result of {@link Brainy.maintenanceDebt}. Brainy
- * performs no thresholding, polling, or estimation over this data — it is a
- * pure passthrough of each active provider's own self-report (the provider
- * owns the numbers; the operator owns the policy).
- */
-export interface MaintenanceDebtReport {
- vector: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt }
- metadata: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt }
- graph: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt }
-}
-
/**
* How long a failed aggregation-backfill walk suppresses fresh walk attempts.
* Within the window, queries rethrow the recorded failure instantly (loud,
@@ -502,15 +396,6 @@ export interface MaintenanceDebtReport {
*/
const AGGREGATION_BACKFILL_RETRY_COOLDOWN_MS = 30_000
-/**
- * Time budget for the auto-compaction pass at close() (8.9.0). Bounds how long
- * a clean shutdown spends reclaiming history backlog — an early stop is a
- * consistent prefix and the next close/explicit pass resumes. Explicit
- * `compactHistory()` calls are unbounded unless the caller passes their own
- * `timeBudgetMs` (maintenance windows choose their own budgets).
- */
-const CLOSE_COMPACTION_BUDGET_MS = 5_000
-
/**
* The main Brainy class - Clean, Beautiful, Powerful
* REAL IMPLEMENTATION - No stubs, no mocks
@@ -531,19 +416,6 @@ export class Brainy implements BrainyInterface {
private static sigintListener?: () => void
private static beforeExitListener?: () => void
- /** True while the `beforeExit` pass is running its flushes. Node re-emits
- * 'beforeExit' after every loop drain and that pass schedules async work, so
- * a second emit can arrive on top of the first; it returns instead of
- * stacking a parallel pass. NOT a one-shot: every genuine drain still gets a
- * flush. See {@link registerShutdownHooks}. */
- private static beforeExitFlushInFlight = false
-
- /** Whether the drained-event-loop notice has been printed for this
- * registration cycle. Printed ONCE — `console.log` to a pipe is itself
- * event-loop work, so narrating on every emit would keep the loop turning
- * and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */
- private static beforeExitNarrated = false
-
/** Poll cadence (ms) for the migration LOCK when a provider exposes no
* event-driven `whenMigrationComplete()` signal. See {@link awaitMigrationLock}. */
private static readonly MIGRATION_POLL_INTERVAL_MS = 250
@@ -575,24 +447,9 @@ export class Brainy implements BrainyInterface {
* store has assigned the batch generation by then; for single-op writes it
* reads the post-write watermark. The arrow body reads `generationStore`
* lazily, so it is safe to define before `init()` assigns the store.
- * Metadata/vector index writes use the bootstrap-honest twin
- * {@link indexWriteGeneration} below.
*/
private readonly graphWriteGeneration = (): bigint =>
BigInt(this.generationStore.generation())
- /**
- * The metadata/vector twin of {@link graphWriteGeneration}, honest about
- * bootstrap: while generation stamping is inactive (init-time
- * infrastructure writes, e.g. the VFS root, applied via
- * `runWithoutGeneration`) there IS no commit generation — this resolves to
- * `undefined` so a provider records "unstamped", never a fabricated 0.
- * The graph thunk keeps its non-optional `bigint` contract (no graph
- * writes occur during bootstrap).
- */
- private readonly indexWriteGeneration = (): bigint | undefined =>
- this._generationStampingActive
- ? BigInt(this.generationStore.generation())
- : undefined
/** Lazily built host surface shared by every `Db` value of this brain. */
private _dbHost?: DbHost
/**
@@ -663,6 +520,8 @@ export class Brainy implements BrainyInterface {
/** One-shot guard so the degraded-reads warning fires once per degraded window
* (reset when the degraded state clears). See {@link warnIfReadsDegraded}. */
private _degradedReadWarned = false
+ /** One-shot guard so the metadata cold-open consistency probe runs once per brain. */
+ private _metadataConsistencyProbed = false
/** Graph-adjacency cold-load consistency: verified-live this session (one-shot). */
private _graphAdjacencyVerified = false
/** Re-entrancy guard: a verify (rebuild → reads) is in flight. */
@@ -752,142 +611,6 @@ export class Brainy implements BrainyInterface {
private _pendingMigrationRunner?: MigrationRunner // Deferred migration runner for large datasets
private _aggregationIndex?: AggregationIndex // Incremental aggregation engine
private _aggregationBackfillFlight: Promise | null = null // Single-flight backfill walk
- private _aggregationCatchUpFlight: Promise | null = null // Single-flight behind-stamp catch-up
-
- // ENGINE-OWNED PERSISTENCE CADENCE (SELF-ENGINE-LIFECYCLE-SPRINT):
- // write-count / interval / idle triggers → ONE background flush at a time.
- // Write acks NEVER await it; a failed background flush is LOUD and re-armed.
- private _persistDirtyWrites = 0
- private _persistLastFlushAt = Date.now()
- /**
- * Whether a write has been committed since the last flush that ran. THE
- * ENGINE DOES NO PERIODIC WORK WITHOUT A CAUSE: a brain nobody has written
- * to has nothing to make durable, and a flush over it must cost nothing and
- * say nothing. Before this, a flush called every provider, stamped the
- * watermarks, persisted the generation counter and re-stamped the entity
- * tree whether or not anything had changed — roughly 28 writes for a store
- * that had not moved.
- *
- * WHAT THIS DOES NOT EXPLAIN, stated so nobody reads it as solved: a
- * production process holding 21 brains printed "All indexes flushed to disk
- * in 216-601ms" per brain every ~35s and idled at 1.26 cores with no writes
- * for ten minutes. This engine's cadence is WRITE-DRIVEN — every trigger
- * runs through noteWriteForPersistence, which only a committed write calls —
- * so something was calling flush() on those brains, and this gate makes such
- * a call free rather than accounting for it. The caller is still unidentified.
- */
- private _dirtySinceLastFlush = false
- private _persistIdleTimer: ReturnType | null = null
- private _persistBackgroundFlight: Promise | null = null
-
- /**
- * FLUSH IS SINGLE-FLIGHT, AND THE QUEUE IS ONE DEEP. `_flushInFlight` is the
- * flush body actually running; `_flushFollowUp` is the AT MOST ONE flush
- * queued behind it. Every caller — the write cadence, the cross-process
- * flush-request watcher, an application calling `flush()` directly — either
- * runs (nothing in flight), or joins the single queued follow-up.
- *
- * WHY A FOLLOW-UP RATHER THAN JOINING THE RUNNING FLUSH: a caller flushes to
- * make ITS writes durable, and those writes may have landed after the
- * running flush read its state. Joining would return "flushed" over data
- * that was never persisted. Chaining one follow-up costs nothing when there
- * is nothing new (a clean brain's flush returns immediately — see
- * `_dirtySinceLastFlush`) and is correct when there is.
- *
- * MEASURED, in the production shutdown this was written for: two
- * "Flushing Brainy indexes and caches to disk..." runs overlapping 3s
- * apart on one brain, their walls growing 295ms → 4.9s as they contended
- * for the same providers.
- *
- * THE WAITER IS SETTLED BY THE MACHINE, NEVER BY A PROMISE CHAIN. The queue
- * is a BARE DEFERRED (`_flushQueued` plus its `_flushQueuedSettle` handles),
- * not `leader.then(() => this.flush())`. A chained follow-up is settled only
- * by resolving the very promise the leader is being awaited through, so the
- * moment anything inside a flush body awaits `flush()` the graph closes on
- * itself and NOBODY resolves — an unbounded hang, not a slow flush. Here the
- * leader never awaits the queue: its `finally` PROMOTES the waiter to a new
- * leader and settles the deferred from that run, and the leader's own
- * promise settles without waiting for it. Every exit — the leader
- * resolving, the leader REJECTING, the promoted run rejecting — runs the
- * same promotion, so a queued caller is always settled exactly once.
- */
- private _flushInFlight: Promise | null = null
- private _flushQueued: Promise | null = null
- private _flushQueuedSettle: {
- resolve: () => void
- reject: (error: unknown) => void
- } | null = null
- /** Flush bodies that got past the single-flight gate (pinned by tests). */
- private _flushBodyRuns = 0
- /** Flush bodies running right now, and the high-water mark — which the
- * single-flight law requires to stay at 1 (pinned by tests). */
- private _flushBodiesActive = 0
- private _flushConcurrencyPeak = 0
-
- // DEFERRED EMBEDDING (MT5): pending markers are LOG RECORDS — an
- // embed.pending record rides the deferred write's own commit fact and
- // embed.landed rides the landing commit; this set is the in-memory
- // fast-path index, rebuilt at open by folding the log's marker records.
- // ONE background worker drains it. A crash can delay a vector, never
- // lose one.
- private _pendingEmbedIds = new Set()
- private _embedWorkerFlight: Promise | null = null
-
- /**
- * Ids cleared from {@link _pendingEmbedIds} with NO durable disarming record
- * behind them — today exactly one case: a pending row that still EXISTS but
- * carries no embeddable data, which the worker reaps in memory only. The log
- * still says those ids are pending, so the pending-embed CHECKPOINT must
- * carry them: the checkpoint's contract is "as of generation G the LOG's
- * pending set was exactly this list", and a checkpoint that quietly dropped
- * an id the log still arms would make the bounded fold disagree with a full
- * fold from generation 1 — the one divergence that could lose a vector.
- * Bounded by the number of such rows; an id leaves when it is re-enqueued or
- * durably disarmed.
- */
- private _pendingEmbedUndurableClears = new Set()
-
- /**
- * Pending-set transitions (enqueue/clear) since the last checkpoint attempt —
- * the checkpoint CADENCE. One mechanism, one hardcoded default, no knob and
- * no timer (nothing to leave running after close).
- */
- private _pendingEmbedCheckpointTransitions = 0
-
- /**
- * A checkpoint is OWED: the cadence came due (or the set drained) and no
- * write has satisfied it yet. It stays armed across attempts the durability
- * law refuses, so the next transition that CAN be checkpointed is.
- */
- private _pendingEmbedCheckpointDue = false
-
- /** Single-flight guard for the fire-and-forget checkpoint write. */
- private _pendingEmbedCheckpointFlight: Promise | null = null
-
- /**
- * What the last pending-embed recovery fold actually did — the bound it
- * used, where it started, and how many facts it read. The narration's
- * source, and the accounting a pin reads instead of a clock.
- */
- private _pendingEmbedFoldReport: {
- bound: 'checkpoint' | 'low-water' | 'genesis'
- fromGeneration: number
- factsScanned: number
- seeded: number
- pending: number
- } | null = null
-
- // OPEN-PATH FIX: the background embedding-engine warm kicked off (never
- // awaited) by `performInit()` when `eagerEmbeddings` resolves true. Stored
- // for observability only — `embed()`/`embeddingManager.embed()` already
- // await the engine's OWN singleton init promise internally, so nothing
- // needs to explicitly await this field for correctness. Never rejects on
- // its own: a `.catch` narrates the failure and swallows it so a failed
- // warm never surfaces as an unhandled rejection.
- private _embeddingWarmPromise: Promise | null = null
-
- /** The stored log-authority switch, read once at open (default: tree). */
- private _logAuthority: LogAuthorityRecord = { authority: 'tree' }
// A failed walk latches its error: retries within the cooldown rethrow it
// instantly instead of re-walking, so a tight caller-side retry loop costs
// one loud error per query, never a full store walk per query.
@@ -946,66 +669,13 @@ export class Brainy implements BrainyInterface {
// applies only to instances that were never closed.
private closed = false
- /**
- * THE ONE CLOSE. Set SYNCHRONOUSLY by the first `close()` call, before that
- * call yields, and never cleared — close is terminal. Every later or
- * concurrent caller receives this same promise, so a shutdown with two
- * callers (a host's pool close and the engine's own signal handler) runs
- * ONE teardown, not two.
- *
- * MEASURED, the day this was added: a host that owns shutdown called
- * `close()` on every pooled store at SIGTERM while the engine's signal
- * handler flushed the same instances in parallel and released their writer
- * locks in its own `finally`. One store took 149s to close (148s of it
- * silent) against 24s for its idle siblings, and the same race in a local
- * reproduction printed `Writer fence lost … the lock file is gone` — the
- * handler observing a lock the close it was racing had already released.
- * Two owners of one shutdown; now there is one, whoever calls first.
- */
- private _closeInFlight: Promise | null = null
-
- // Index-build-at-open state. `lazyRebuildCompleted` predates the health-gate
- // law (it named a first-QUERY lazy rebuild) and stays for `getIndexStatus()`
- // API compatibility, but its truth changed: a needed rebuild now runs
- // unconditionally at open() (see `rebuildIndexesIfNeeded`), never deferred to
- // a read, so this simply flips true once that open-time step has run.
- // `lazyRebuildInProgress` / `lazyRebuildPromise` (the first-query rebuild's
- // concurrency guard) are retired with the lazy-build path they served —
- // `ensureIndexesLoaded()` is a read-time CHECK now, never a build.
+ // Lazy rebuild state (Production-scale lazy loading)
+ // Prevents race conditions when multiple queries trigger rebuild simultaneously
+ private lazyRebuildInProgress = false
private lazyRebuildCompleted = false
-
- // Read-gate narration dedup: a degraded-but-serving or not-ready health
- // report narrates via prodLog.warn ONCE per (provider, report.generation) —
- // never once per read. Keyed on the provider instance itself.
- /**
- * The last health narration emitted per provider, keyed by its CONTENT.
- *
- * This used to dedupe on the provider's `generation` counter, which bumps on
- * every ledger mutation and every rebuild boundary — so a provider that
- * bumps its generation on routine work re-emitted the same unchanged health
- * line on every read that consulted it, and a provider that never bumped
- * could suppress a line whose reasons had genuinely changed. The dedupe key
- * is now what the line SAYS: an unchanged verdict is silent however the
- * generation moves, and a changed verdict is always heard.
- */
- private _lastNarratedHealth = new Map()
+ private lazyRebuildPromise: Promise | null = null
constructor(config?: BrainyConfig) {
- // The reserved-field write policy died with the field-addressing law:
- // every metadata name is the user's now (engine scalars write via their
- // dedicated params and read at `system.*`), so there is nothing left for
- // the policy to govern. A config still passing it refuses loudly rather
- // than being silently ignored.
- if (config && 'reservedFieldPolicy' in (config as Record)) {
- throw new Error(
- `reservedFieldPolicy was removed by the field-addressing law: metadata field ` +
- `names are never reserved anymore — every name in the metadata bag is the ` +
- `user's and works like any other field. Set engine scalars via their ` +
- `dedicated params (confidence, weight, subtype, …) and query them as ` +
- `system.. Remove the reservedFieldPolicy option.`
- )
- }
-
// Normalize configuration with defaults
this.config = this.normalizeConfig(config)
@@ -1106,14 +776,14 @@ export class Brainy implements BrainyInterface {
* extends FileSystemStorage`) inherit new methods Brainy adds to
* `FileSystemStorage` / `BaseStorage` automatically — `typeof` walks the
* prototype chain, so there's no in-package version skew to worry about as
- * long as the plugin's own dist resolves `@soulcraftlabs/brainy` dynamically
+ * long as the plugin's own dist resolves `@soulcraft/brainy` dynamically
* (which Cortex 2.2.x onward does — see
* `node_modules/@soulcraft/cor/dist/storage/mmapFileSystemStorage.js`).
*
* This helper exists for the **build/install** failure modes the import
* resolution can't catch:
* - Stale `node_modules` left over from a prior `bun install` against
- * `@soulcraftlabs/brainy ≤7.20.x`.
+ * `@soulcraft/brainy ≤7.20.x`.
* - Lockfile drift pinning brainy below the version that introduced the
* method.
* - Docker layer caches that reuse a `node_modules` from an earlier image.
@@ -1159,17 +829,6 @@ export class Brainy implements BrainyInterface {
}
}
- /**
- * Factory hook for the generation store, so an engine built on top of this
- * reference implementation can substitute a `GenerationStore` that keeps
- * the same behavioural contract (for example, one backed by a native
- * implementation) — overriding it never changes this engine's own
- * behaviour, since the default implementation is unchanged.
- */
- protected createGenerationStore(storage: BaseStorage): GenerationStore {
- return new GenerationStore(storage)
- }
-
/**
* Initialize Brainy.
*
@@ -1263,86 +922,6 @@ export class Brainy implements BrainyInterface {
configureLogger({ level: LogLevel.DEBUG }) // Enable verbose logging
}
- // OPEN-PATH NARRATION: phase timing across the five named stretches of
- // init — storage init / generation-store open+fold / index init+gate /
- // VFS bootstrap / embedding-warm-started. Each `markPhase()` call records
- // elapsed ms SINCE THE PREVIOUS checkpoint, so the buckets always sum to
- // the pre-integration/warmOnOpen total.
- //
- // THE LAW THIS ENFORCES: an open is never silent for more than
- // OPEN_HEARTBEAT_MS. A production service opening a 16 GB store logged
- // NOTHING for three minutes and then began work — the operator could not
- // tell a slow open from a hung one, and restarted into the same wall.
- // Two mechanisms, both on the always-visible narration channel (the old
- // breakdown used `prodLog.warn`, which production clamps away — that is
- // why the three minutes were silent):
- // - a heartbeat that names the phase currently running and its elapsed
- // wall, every OPEN_HEARTBEAT_MS, for as long as the open lasts;
- // - one line per phase AS IT ENDS, naming its wall and its cause, for
- // any phase over OPEN_PHASE_NARRATE_MS.
- // The heartbeat is unref'd and cleared in the `finally` below, so it can
- // neither hold the process open nor outlive a failed init. It cannot fire
- // inside a phase that blocks the event loop synchronously; such a phase
- // must narrate its own progress (the generation-log fold does).
- const OPEN_HEARTBEAT_MS = 5_000
- const OPEN_PHASE_NARRATE_MS = 2_000
- /** Phase order + what each one is paying for, quoted in its narration. */
- const OPEN_PHASES: ReadonlyArray<{ name: string; cause: string }> = [
- { name: 'storage-init', cause: 'opening the store and loading its count ledger' },
- {
- name: 'generation-store-open-fold',
- cause: 'opening the generation store: crash-recovery replay/fold, derived-family registration, format handshake'
- },
- { name: 'index-init-gate', cause: 'constructing the derived indexes and gating them for serving' },
- { name: 'vfs-bootstrap', cause: 'bootstrapping the virtual filesystem' },
- { name: 'embedding-warm-started', cause: 'starting the background embedding warm' }
- ]
- const initStart = Date.now()
- let lastPhaseCheckpoint = initStart
- let currentPhaseIndex = 0
- const phaseTimingsMs: Record = {}
- const openHeartbeat: ReturnType = setInterval(() => {
- const phase = OPEN_PHASES[currentPhaseIndex]
- if (!phase) return
- prodLog.narrate(
- `[Brainy] open: still in phase ${currentPhaseIndex + 1}/${OPEN_PHASES.length} ` +
- `"${phase.name}" after ${Math.round((Date.now() - lastPhaseCheckpoint) / 1000)}s ` +
- `(${Math.round((Date.now() - initStart) / 1000)}s into the open) — ${phase.cause}`
- )
- }, OPEN_HEARTBEAT_MS)
- if (typeof openHeartbeat.unref === 'function') openHeartbeat.unref()
- /**
- * Narrate one STEP inside a phase when it turns out to be expensive.
- * A phase that costs a minute and names only itself tells an operator
- * where to look but not what to look at; this names the step. Silent
- * under OPEN_PHASE_NARRATE_MS, so a fast open says nothing extra.
- */
- const step = async (name: string, cause: string, run: () => Promise): Promise => {
- const startedAt = Date.now()
- try {
- return await run()
- } finally {
- const elapsed = Date.now() - startedAt
- if (elapsed >= OPEN_PHASE_NARRATE_MS) {
- prodLog.narrate(`[Brainy] open: step "${name}" took ${elapsed}ms — ${cause}`)
- }
- }
- }
- const markPhase = (name: string): void => {
- const now = Date.now()
- const elapsed = now - lastPhaseCheckpoint
- phaseTimingsMs[name] = elapsed
- lastPhaseCheckpoint = now
- const finished = OPEN_PHASES[currentPhaseIndex]
- if (elapsed >= OPEN_PHASE_NARRATE_MS && finished && finished.name === name) {
- prodLog.narrate(
- `[Brainy] open: phase ${currentPhaseIndex + 1}/${OPEN_PHASES.length} ` +
- `"${name}" finished in ${elapsed}ms — ${finished.cause}`
- )
- }
- currentPhaseIndex++
- }
-
try {
// Auto-detect and activate plugins BEFORE storage setup
// so plugin-provided storage factories (e.g., filesystem override from cor) are available
@@ -1403,7 +982,7 @@ export class Brainy implements BrainyInterface {
`and the flush-request RPC are disabled for this directory. ` +
`Likely fix: clean install (\`rm -rf node_modules bun.lockb && ` +
`bun install\`) or rebuild your container image to refresh ` +
- `\`@soulcraftlabs/brainy\` to ≥7.21. See docs/concepts/storage-adapters.md.`
+ `\`@soulcraft/brainy\` to ≥7.21. See docs/concepts/storage-adapters.md.`
)
} else {
console.warn(
@@ -1414,12 +993,6 @@ export class Brainy implements BrainyInterface {
}
}
- // PHASE 1 of 5 — "storage init": plugin/legacy-layout bootstrap,
- // storage adapter construction+init, the OS-limit check, and the
- // writer-lock claim, all folded into one bucket (everything above this
- // line since performInit started).
- markPhase('storage-init')
-
// 8.0 generational MVCC: open the record layer BEFORE any index is
// created or loaded. Crash recovery may rewrite canonical entity files
// (restoring before-images of an uncommitted transaction), and every
@@ -1427,13 +1000,10 @@ export class Brainy implements BrainyInterface {
// guarantees indexes never observe rolled-back state. Reader-mode
// instances skip recovery (readers never write; the next writer
// repairs).
- this.generationStore = this.createGenerationStore(this.storage)
- const generationOpenResult = await step(
- 'generation-store.open',
- 'reading the generation manifest and committed ranges, opening the fact log and the ' +
- 'packed segment tier, and folding any crash-recovery replay',
- () => this.generationStore.open({ readOnly: this.config.mode === 'reader' })
- )
+ this.generationStore = new GenerationStore(this.storage)
+ const generationOpenResult = await this.generationStore.open({
+ readOnly: this.config.mode === 'reader'
+ })
// The generation fact log is CANONICAL state, not a derived index — no
// sweeper, GC, or blob-lifecycle path may ever delete under it. Declare
@@ -1471,11 +1041,7 @@ export class Brainy implements BrainyInterface {
// rollup invariants against the log head + live counters. Loud on
// genuine incoherence (repairIndex heals), silent on absent/coherent,
// benign-behind refreshes at the next flush. Never blocks open.
- await step(
- 'verify-entity-tree-stamp',
- 'comparing the entity tree\'s stamped generation and rollups against the store',
- () => this.verifyEntityTreeStamp()
- )
+ await this.verifyEntityTreeStamp()
// 8.0 ⇄ native-provider version handshake: load the on-disk brain-format
// marker (`_system/brain-format.json`) into an in-memory field NOW —
@@ -1487,11 +1053,7 @@ export class Brainy implements BrainyInterface {
// them from the canonical records and then re-stamps the marker AFTER the
// rebuild verifies (non-destructive: a crash mid-rebuild leaves the old /
// absent marker, so the next open idempotently re-rebuilds).
- this._brainFormat = await step(
- 'read-brain-format',
- 'reading the on-disk format marker that decides whether the derived indexes are stale',
- () => readBrainFormat(this.storage)
- )
+ this._brainFormat = await readBrainFormat(this.storage)
this._indexEpochStale =
this._brainFormat === null || this._brainFormat.indexEpoch !== EXPECTED_INDEX_EPOCH
@@ -1502,19 +1064,9 @@ export class Brainy implements BrainyInterface {
// upgrade verifies + stamps; retained on failure. No-op for a reader, for
// non-filesystem storage, or for a brain with no persisted data.
if (this._indexEpochStale && this.config.migrationBackup && !this.isReadOnly) {
- await step(
- 'pre-upgrade-backup',
- 'snapshotting the brain directory before a one-time format rebuild (migrationBackup)',
- () => this.createMigrationBackupIfNeeded()
- )
+ await this.createMigrationBackupIfNeeded()
}
- // PHASE 2 of 5 — "generation-store open+fold": GenerationStore
- // construction+open (crash-recovery replay/rollback fold), the
- // derived-family registration, the fact-scan seam, the entity-tree
- // stamp check, the brain-format handshake, and the pre-upgrade backup.
- markPhase('generation-store-open-fold')
-
// Provider: embeddings (reassign embedder if plugin provides one)
const embeddingProvider = this.pluginRegistry.getProvider('embeddings')
if (embeddingProvider) {
@@ -1589,42 +1141,6 @@ export class Brainy implements BrainyInterface {
this.graphIndex = graphIndex
}
- // Fact-log v2 mint seam: after-image records carry minted dense ints,
- // and the ONE authority for those assignments is the metadata index's
- // id mapper (append-only getOrAssign — a rebuilt mapper reproduces
- // them exactly). The generation store cannot know the mapper, so the
- // mint thunk is injected here, immediately after the index is ready;
- // installing it is what flips the fact log's LIVE writes to the v2
- // segment format. A configuration whose mapper is unavailable throws
- // at mint time — an int of 0 is never written.
- this.generationStore.setIntMinter((kind, id) => {
- const mapper = this.metadataIndex?.getIdMapper?.()
- if (!mapper || typeof mapper.getOrAssign !== 'function') {
- throw new Error(
- `fact log v2: cannot mint the ${kind} int for ${id} — the metadata index's ` +
- `id mapper is unavailable on this configuration; refusing to write an ` +
- `after-image without a reproducible int`
- )
- }
- const minted = mapper.getOrAssign(id, undefined)
- const asBigint = typeof minted === 'bigint' ? minted : BigInt(minted)
- // THE RESERVED-ROOT EXEMPTION: the VFS root (the all-zeros UUID) is
- // minted int 0 BY CONSTRUCTION at genesis on existing brains — the
- // one legitimate zero in the id space. Zero for ANY other id is a
- // corrupt mint and refuses. (Without this, every existing brain's
- // adoption oracle false-flagged its own root and refused the flip.)
- const isReservedRoot =
- asBigint === 0n && id === '00000000-0000-0000-0000-000000000000'
- if (asBigint < 0n || (asBigint === 0n && !isReservedRoot)) {
- throw new Error(
- `fact log v2: the id mapper minted ${asBigint} for ${kind} ${id} — ` +
- `minted ints are positive (int 0 is reserved for the VFS root alone); ` +
- `refusing to write`
- )
- }
- return asBigint
- })
-
// Eager cold-load (readiness contract). A provider that persists its
// derived state exposes init?(): trigger the load NOW — AFTER
// metadataIndex.init() above (the id-mapper is hydrated first, so a
@@ -1669,64 +1185,20 @@ export class Brainy implements BrainyInterface {
`[Brainy] Rebuilding indexes after crash recovery rolled back ` +
`${generationOpenResult.rolledBackGenerations} uncommitted transaction(s)`
)
- // SELF-REBUILD DEFERENCE, same law as the open gate: a provider that
- // is already rebuilding itself from canonical is doing exactly this
- // work. Kicking a second rebuild on top of it is redundant at best.
- // Safe by ordering: the crash-recovery fold ran in the generation
- // store's open, BEFORE any provider was constructed, so a provider
- // rebuilding now is reading the repaired canonical records.
- const kick = async (leg: string, provider: { rebuild: () => Promise }) => {
- const rebuilding = assessProviderRebuild(provider)
- if (rebuilding) {
- prodLog.narrate(
- `[Brainy] crash-recovery rebuild: the ${leg} provider is already ` +
- `${describeRebuildProgress(rebuilding)} from canonical — not kicking a second one.`
- )
- return
- }
- await provider.rebuild()
- }
await Promise.all([
- kick('metadata', this.metadataIndex),
- kick('vector', this.index as unknown as { rebuild: () => Promise }),
- kick('graph', this.graphIndex)
+ this.metadataIndex.rebuild(),
+ this.index.rebuild(),
+ this.graphIndex.rebuild()
])
}
- // METADATA WATERMARK CATCHUP: the JS metadata index computed its
- // three-way watermark verdict inside metadataIndex.init() above,
- // against the generation store's now-FINAL committed generation (the
- // crash-recovery fold above — the durable-at-ack replay of acked
- // writes whose canonical bytes hadn't reached disk — has already run,
- // and any rolled-back-transaction rebuild just above already brought
- // every index current, so the verdict is consumed here whether or not
- // that rebuild ran). Consumed BEFORE the rebuild gate below and BEFORE
- // this open serves any read — the cure for the class of bug where
- // canonical get()/counts recover a crash-window write but find()
- // keeps serving the metadata index's pre-crash state (the index
- // flushes only periodically, not per-commit).
- await this.consumeMetadataWatermarkVerdict(generationOpenResult.rolledBackGenerations > 0)
-
// 8.0 versioned-provider replay-gap check: a provider whose persisted
// index generation is behind the storage layer's committed generation
// replays the gap itself (post-commit applier contract) — surface the
// gap for observability.
for (const provider of this.versionedIndexProviders()) {
const providerGen = provider.generation()
- // Defensive finite-integer guard: committedGeneration() is validated
- // at the store's open (torn artifacts discard, narrated) — but a
- // RangeError here would kill the whole open, so the consumer guards
- // too. A non-finite value narrates and skips the gap check (the
- // provider's own replay contract still governs).
- const committedRaw = this.generationStore.committedGeneration()
- if (!Number.isSafeInteger(committedRaw) || committedRaw < 0) {
- prodLog.warn(
- `[Brainy] committed generation is non-integer (${String(committedRaw)}) at ` +
- `init — torn-artifact survivor; skipping the provider replay-gap check`
- )
- continue
- }
- const committed = BigInt(committedRaw)
+ const committed = BigInt(this.generationStore.committedGeneration())
if (providerGen < committed) {
prodLog.info(
`[Brainy] Versioned index provider is at generation ${providerGen} ` +
@@ -1773,38 +1245,12 @@ export class Brainy implements BrainyInterface {
}).backfillBlobHistoryRefCountsIfNeeded()
}
- // LEG C (zero-norm/unvector-door law): migrate a legacy zero-norm VFS
- // root BEFORE the vector-leg open gate below ever compares the
- // canonical vectored-noun count against the vector index's size — see
- // migrateLegacyZeroNormVfsRootIfNeeded's JSDoc for why this is a safe
- // O(1) exception to "nothing at open may scale with brain size", and
- // why it must run here rather than waiting on VirtualFileSystem's own
- // (VFS-instance-gated) lazy migration.
- await this.migrateLegacyZeroNormVfsRootIfNeeded()
-
- // Rebuild indexes if needed for existing data. Runs to completion before
- // init() returns — there is no more first-query lazy path, so the flag
- // below (kept for getIndexStatus() API compatibility) simply flips true
- // once this open-time step has run.
- await step(
- 'rebuild-indexes-if-needed',
- 'the derived-index gate: each family\'s readiness verdict, and any build it asks for',
- () => this.rebuildIndexesIfNeeded()
- )
- this.lazyRebuildCompleted = true
+ // Rebuild indexes if needed for existing data
+ await this.rebuildIndexesIfNeeded()
// Check for pending data migrations
await this.checkMigrations()
- // PHASE 3 of 5 — "index init+gate": provider wiring (embeddings,
- // cache, roaring, msgpack, sort:topK, distance), HNSW/metadata/graph
- // index construction, the eager cold-load, id-resolver + connections-
- // codec wiring, crash-recovery index rebuild, the replay-gap check,
- // legacy VFS blob adoption, blob-history backfill, the legacy
- // zero-norm VFS root migration, and the rebuildIndexesIfNeeded() gate
- // + migration check.
- markPhase('index-init-gate')
-
// Register shutdown hooks for graceful count flushing (once globally)
if (!Brainy.shutdownHooksRegisteredGlobally) {
this.registerShutdownHooks()
@@ -1863,11 +1309,7 @@ export class Brainy implements BrainyInterface {
// Initialize VFS: Ensure VFS is ready when accessed as property
// This eliminates need for separate vfs.init() calls - zero additional complexity
this._vfs = new VirtualFileSystem(this)
- await step(
- 'vfs.init',
- 'creating or adopting the VFS root and wiring the path resolver',
- () => this._vfs!.init()
- )
+ await this._vfs.init()
this._vfsInitialized = true // Mark VFS as fully initialized
// 8.0 MVCC: infrastructure bootstrap (VFS root, etc.) is now the
@@ -1877,145 +1319,15 @@ export class Brainy implements BrainyInterface {
this._generationStampingActive = true
}
- // LOG-AUTHORITY SWITCH (checked at open only). A STORED artifact
- // always wins: an already-flipped brain runs durable-at-ack; an
- // explicitly-recorded tree posture is honored. With NO artifact, the
- // 10.0.0 FLEET DEFAULT is ADOPT-AT-OPEN (config logAuthority:
- // 'adopt'): the verification oracle gates the flip — curable
- // divergences are baseline-backfilled, the brain flips ONLY on green,
- // and a brain that cannot go green STAYS tree-authoritative LOUDLY
- // with the refusal recorded (cheap subsequent opens; an operator
- // re-runs adoptLogAuthority() after fixing the divergence).
- // 'defer' is the documented opt-out: no automatic adoption.
- if (!this.isReadOnly) {
- const storedArtifact = await this.storage
- .readRawObject(LOG_AUTHORITY_PATH)
- .catch(() => null)
- const authority = await step(
- 'read-log-authority',
- 'reading the stored storage-authority artifact',
- () => readLogAuthority(this.storage)
- )
- this._logAuthority = authority
- if (authority.authority === 'log') {
- this.generationStore.setLogDurability('at-ack')
- prodLog.info('[Brainy] storage authority: generation log (durable-at-ack enabled)')
- } else if (
- storedArtifact === null &&
- this.config.logAuthority === 'adopt' &&
- this.generationStore.getFactLog() !== null
- ) {
- try {
- await step(
- 'adopt-log-authority',
- 'the adoption oracle: verifying the log against canonical before flipping this ' +
- 'brain to durable-at-ack, and backfilling any curable divergence',
- () => this.adoptLogAuthority()
- )
- prodLog.info(
- '[Brainy] storage authority adopted at open: generation log ' +
- '(fleet default; oracle green; durable-at-ack enabled)'
- )
- } catch (err) {
- // The guarded ruling: a brain that cannot verify STAYS tree,
- // loudly, with the refusal recorded so subsequent opens are
- // cheap. Never a silent half-state; never a failed open.
- const reason = (err as Error).message
- prodLog.warn(
- `[Brainy] log-authority adoption REFUSED at open — this brain stays ` +
- `tree-authoritative until an operator resolves the divergence and ` +
- `re-runs adoptLogAuthority(). Reason: ${reason}`
- )
- try {
- const refusal: LogAuthorityRecord = {
- authority: 'tree',
- adoptRefusal: { at: Date.now(), reason: reason.slice(0, 500) }
- }
- await this.storage.writeRawObject(LOG_AUTHORITY_PATH, refusal)
- this._logAuthority = refusal
- } catch {
- // Unrecordable refusal = the next open retries the oracle —
- // the conservative outcome.
- }
- }
- }
- }
-
- // MT5 crash recovery — REPLAY, NOT LISTING: the pending-embed markers
- // live IN the generation log (embed.pending rides the deferred write's
- // own fact; embed.landed rides the landing commit), so recovery folds
- // the log's marker records back into the in-memory set — after the
- // one-time bridge migrates any sidecar files a pre-log build left
- // behind — and resumes the worker in the background. A crash between
- // a deferred write's ack and its background embed DELAYED a vector;
- // this is where it lands.
- if (!this.isReadOnly) {
- // Foreground, as the crash-recovery contract pins it: a reopened brain
- // has its markers re-armed when open() returns. The low-water mark
- // bounds this to the log's tail on any brain that has ever drained —
- // milliseconds — so the foreground cost is the unmarked first open
- // only, once per upgraded brain.
- try {
- await step(
- 'bridge-pending-embed-sidecars',
- 'migrating any pre-log deferred-embed marker files into the generation log',
- () => this.bridgeLegacyPendingEmbedSidecars()
- )
- await step(
- 'recover-pending-embeds',
- 'folding the generation log\'s deferred-embed markers (from the low-water mark) into the pending set',
- () => this.recoverPendingEmbedsFromLog()
- )
- if (this._pendingEmbedIds.size > 0) {
- prodLog.info(
- `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` +
- `session — resuming in the background`
- )
- const t = setTimeout(() => this.kickEmbedWorker(), 0)
- ;(t as { unref?: () => void }).unref?.()
- }
- } catch (err) {
- prodLog.warn(
- `[Brainy] pending-embed recovery failed: ${(err as Error).message} — ` +
- `the log's markers remain durable; recovery retries next open`
- )
- }
- }
-
- // PHASE 4 of 5 — "VFS bootstrap": shutdown-hook registration, blob
- // storage init, the provider-summary log, flipping `initialized`,
- // the migration-lock wait, VFS construction+init, flipping generation
- // stamping active, the log-authority adopt/oracle check, and
- // pending-embed crash recovery.
- markPhase('vfs-bootstrap')
-
- // Eager embedding initialization — BACKGROUND WARM (open-path fix).
+ // Eager embedding initialization.
//
- // Adaptive default (8.0): the WASM embedding engine eagerly WARMS
+ // Adaptive default (8.0): the WASM embedding engine eagerly initializes
// during init() WHENEVER it is the active embedder — i.e. no native
// 'embeddings' provider has taken over — and the instance is a writer
// (not reader-mode) outside of unit tests. The WASM module (≈93MB with
- // the embedded model) takes 90-140s to compile on throttled CPUs.
- //
- // Historically this AWAITED `embeddingManager.init()` INLINE, so every
- // writer's open() blocked on the compile — N concurrent opens all
- // queued on the ONE process-global singleton (an ~80x contention
- // multiplier measured in a production restart storm: 90,017ms busy vs
- // 1,117ms quiet). The engine only needs to be ready before the FIRST
- // REAL embed() call, not before init() returns, so this now only
- // STARTS the warm and moves on — init() never waits for it.
- //
- // No double-await needed for correctness: `this.embed()` (~line 15420)
- // delegates to `this.embedder`, which for the default engine is
- // `embeddingManager.getEmbeddingFunction()` → `embeddingManager.embed()`
- // (src/embeddings/EmbeddingManager.ts). That method calls `await
- // this.init()` FIRST, and `init()` itself serializes every concurrent
- // caller onto ONE shared `globalInitPromise` — so the first real
- // embed() automatically waits for whichever finishes first: this
- // background warm (if still running) or a fresh init() (if the warm
- // hasn't reached this code yet, e.g. `eagerEmbeddings: false`).
- // Verified by reading both call sites; `_embeddingWarmPromise` below
- // is stored for observability only, never re-awaited by embed().
+ // the embedded model) takes 90-140s to compile on throttled CPUs; paying
+ // that during boot rather than on the first embed()-driven call is the
+ // right default for the overwhelmingly common single-process server.
//
// Skipped automatically when:
// - a native 'embeddings' provider is registered (it owns embeddings;
@@ -2023,8 +1335,8 @@ export class Brainy implements BrainyInterface {
// - reader-mode (readers don't embed — they query existing vectors),
// - unit-test mode (tests must stay fast and use the mock embedder).
//
- // `eagerEmbeddings: false` keeps meaning "no warm at all" — fully lazy,
- // the first embed() call pays the full cost inline, same as before.
+ // `eagerEmbeddings: false` is the explicit override to force lazy init
+ // (first-embed) even when this instance is the active embedder.
const isUnitTestMode = isDeterministicEmbedMode()
const eager = this.config.eagerEmbeddings ?? true
if (
@@ -2033,45 +1345,9 @@ export class Brainy implements BrainyInterface {
this.config.mode !== 'reader' &&
!isUnitTestMode
) {
- const warmStart = Date.now()
- console.log('Background embedding-engine warm started (init() does not wait for it)...')
- this._embeddingWarmPromise = embeddingManager
- .init()
- .then(() => {
- prodLog.info(
- `[Brainy] background embedding-engine warm complete in ${Date.now() - warmStart}ms`
- )
- })
- .catch((err) => {
- // Loud, never silent: a warm that fails to compile must be
- // heard NOW, not discovered as a mystery latency spike on
- // whichever request happens to trigger the first real embed().
- // That first embed() call still retries init() itself (the
- // singleton promise contract above) and surfaces its own typed
- // error to its caller — this is the immediate, background echo.
- prodLog.warn(
- `[Brainy] background embedding-engine warm FAILED: ` +
- `${(err as Error).message} — the first embed() call will retry ` +
- `initialization and surface the error there`
- )
- })
- }
-
- // PHASE 5 of 5 — "embedding-warm-started": just the synchronous cost
- // of kicking off the background warm above (the warm's own compile
- // time is NOT included — that's the whole point of backgrounding it).
- markPhase('embedding-warm-started')
- {
- const totalOpenMs = Date.now() - initStart
- if (totalOpenMs > 2000) {
- const phaseList = Object.entries(phaseTimingsMs)
- .map(([name, ms]) => `${name}=${ms}ms`)
- .join(', ')
- prodLog.narrate(
- `[Brainy] slow open: ${totalOpenMs}ms total (${phaseList}) — see the ` +
- `phase breakdown above to find which one to investigate first`
- )
- }
+ console.log('Eager embedding initialization enabled...')
+ await embeddingManager.init()
+ console.log('Embedding engine ready')
}
// Integration Hub initialization
@@ -2097,17 +1373,6 @@ export class Brainy implements BrainyInterface {
})
}
- // Eager index warm (operator opt-in, `warmOnOpen: true`). Runs AFTER
- // every step above — index construction, crash recovery, migrations,
- // VFS bootstrap — never in place of any of it, so `warm()` always
- // operates on a fully-initialized brain. Blocking BY DESIGN: the
- // operator traded a longer startup for a first-request that runs at
- // steady-state cost instead of paying demand-load latency on the
- // critical path. See the `warmOnOpen` JSDoc in brainy.types.ts.
- if (this.config.warmOnOpen) {
- await this.warm()
- }
-
// Resolve ready Promise - consumers awaiting brain.ready will now proceed
if (this._readyResolve) {
this._readyResolve()
@@ -2124,15 +1389,7 @@ export class Brainy implements BrainyInterface {
if (error instanceof Error && (error as Error & { code?: string }).code === 'BRAINY_WRITER_LOCKED') {
throw error
}
- // Wrap with the original as `cause` so the originating frame (a plugin's
- // own file:line, e.g. a provider boot failure) survives to the caller's
- // log — a plain string interpolation discards both stack and cause.
- const message = error instanceof Error ? error.message : String(error)
- throw new Error(`Failed to initialize Brainy: ${message}`, { cause: error })
- } finally {
- // The open is over — succeeded or failed. Stop the heartbeat here so a
- // failed init never leaves a timer narrating a phase nobody is running.
- clearInterval(openHeartbeat)
+ throw new Error(`Failed to initialize Brainy: ${error}`)
}
}
@@ -2143,204 +1400,83 @@ export class Brainy implements BrainyInterface {
* Critical for Cloud Run, Fargate, Lambda, and other containerized deployments.
*
* Handles:
- * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda) — CLOSES.
- * - SIGINT: Ctrl+C (development/local testing) — CLOSES.
- * - beforeExit: the event loop drained — FLUSHES, and closes NOTHING. A
- * drained loop is not a shutdown; see {@link flushOnDrainedEventLoop}'s
- * contract below.
+ * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda)
+ * - SIGINT: Ctrl+C (development/local testing)
+ * - beforeExit: Node.js cleanup hook (fallback)
*
* NOTE: Registers globally (once for all instances) to avoid MaxListenersExceededWarning
*/
private registerShutdownHooks(): void {
- /**
- * The signal-path shutdown. ONE OWNER PER BRAIN, AND THE PATH IS `close()`.
- *
- * WHAT THIS REPLACED, and why. The handler used to run its own shutdown —
- * a parallel per-component flush, the generation store's close, a second
- * parallel round of component closes, and a `finally` that stopped the
- * flush-request watcher and released the writer lock. That is a SECOND
- * teardown of the same brain, and a host application with its own SIGTERM
- * handler (the shape every pooled deployment has) ran the FIRST one at the
- * same moment. MEASURED in production the day this changed: a host closing
- * seven pooled stores at SIGTERM printed "Shutdown signal received -
- * flushing pending data...", went silent for 148s, printed "Flushed
- * successfully (1 instance)", and the host's own close of that same store
- * returned 1s later — 149s, against 24s for the six stores with no engine
- * work in flight. The same race reproduced locally as
- * `Failed to flush one Brainy instance on shutdown: Writer fence lost …
- * the lock file is gone`: this handler observing a lock that the close it
- * was racing had already released.
- *
- * SO: defer one macrotask, then per instance either STEP ASIDE (a close
- * has begun or finished — its owner owns the flush, the markers and the
- * lock) or `await instance.close()` — the one durable path, identical to
- * what any caller gets. The three laws the old block carried are all
- * satisfied by `close()`, each verified against its code:
- *
- * 1. PER-INSTANCE ISOLATION — kept HERE, in the per-instance try/catch
- * below: one brain's failed close never aborts the loop over the rest.
- * (`close()` itself is per-instance by construction.)
- * 2. THE MARKER IS PART OF SHUTDOWN — `close()` → `closeDurableSteps()`
- * Phase 1 awaits `this.generationStore.close()`, which persists the
- * counter, advances the fold checkpoint and stamps the clean-shutdown
- * marker LAST. That is the step that decides adopt-vs-fold at the next
- * open, and it is the same call the old block made.
- * 3. THE LOCK IS ALWAYS GIVEN UP — `close()`'s terminal releases run
- * whether the durable steps threw or not (its contract: "TWO PARTS, AND
- * THE SECOND IS UNCONDITIONAL"): `stopFlushRequestWatcher()` then
- * `releaseWriterLock()`, then the VFS shutdown and the terminal
- * `closed` flag, and only then is the original failure rethrown.
- * `close()` releases the lock in MORE cases than the old block did — it
- * also drains the metadata write buffer first, so no pending write can
- * land after a successor writer claims the lock.
- */
- const closeOnShutdown = async () => {
+ const flushOnShutdown = async () => {
console.log('Shutdown signal received - flushing pending data...')
- // DEFER ONE MACROTASK. A host application registers its own listener on
- // the same signal, and Node runs listeners in registration order — ours
- // is usually first, because the brain was opened before the host wired
- // its shutdown. Yielding once lets every other listener for this signal
- // run its synchronous prologue, so a host that calls close() gets to be
- // the owner. It is only a courtesy, never the safety: close()'s own
- // single-flight gate is what makes a lost race harmless.
- await new Promise((resolve) => setImmediate(resolve))
-
- let closedCount = 0
- let deferredCount = 0
- let failedCount = 0
- // Snapshot: close() splices Brainy.instances while we iterate.
- for (const instance of [...Brainy.instances]) {
- if (!instance.initialized) continue
- // SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a
- // component close — nothing. Touching a brain whose close is running
- // is the whole defect this handler was rewritten for.
- if (instance.closed || instance._closeInFlight !== null) {
- deferredCount++
- continue
- }
- try {
- // Law 1: this try/catch is the isolation — the loop continues.
- await instance.close()
- closedCount++
- } catch (error) {
- failedCount++
- console.error('Failed to close one Brainy instance on shutdown:', error)
- }
- }
- if (closedCount > 0) {
- console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`)
- }
- if (deferredCount > 0) {
- console.log(
- `${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` +
- `closing — left to the caller that owns that close.`
- )
- }
- if (failedCount > 0) {
- console.error(
- `${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
- `their writer locks were released, but their next open will run crash recovery.`
- )
- }
- }
-
- /**
- * THE DRAINED-EVENT-LOOP PATH. A DRAINED LOOP IS NOT A SHUTDOWN.
- *
- * Node emits `'beforeExit'` whenever the event loop has no REF'd work
- * left — NOT when the process is ending, and with no signal involved. A
- * perfectly healthy script reaches that state routinely: this engine
- * unref's its idle and cadence timers ("an idle brain costs nothing"), so
- * a script awaiting anything those timers drive is, for that instant,
- * a process with no ref'd work and an open brain.
- *
- * MEASURED on the 11.1 rehearsal lane against a copy of a real store: the
- * `beforeExit` listener was wired to the SIGNAL path, so after the heal
- * phase the log printed `Shutdown signal received - flushing pending
- * data...` and `Flushed successfully (1 instance)` with NO signal ever
- * sent, and the script's very next `add()` threw `Brainy instance is not
- * initialized: it was closed via close(). Create a new instance.` The
- * engine had closed a live brain out from under a running script.
- *
- * SO, THE LAW: this path NEVER closes, deregisters, tears down or
- * force-exits anything, and never releases a writer lock. It runs
- * `flush()` — the engine's own non-closing durability door — on each live
- * brain, and leaves every one of them open and usable.
- *
- * WHY flush() AND NOT NOTHING. Each claim checked against the code it
- * names:
- * 1. IT CANNOT CLOSE ANYTHING. `flush()` → `_flushSteps()` persists
- * DERIVED state only: the count ledger, the metadata/graph/vector
- * projections, the generation counter, aggregation state, the
- * entity-tree stamp. It closes no component, deactivates no plugin,
- * touches neither `initialized` nor `closed`, and never calls
- * `releaseWriterLock()` — the clean-shutdown marker is written by
- * `generationStore.close()` alone, reached only from `close()`.
- * 2. IT CANNOT RACE A LATER WRITE INTO CORRUPTION. A background flush
- * concurrent with live writes is the engine's ORDINARY steady state:
- * `noteWriteForPersistence()` kicks exactly this call off an unref'd
- * timer on every busy brain. `flush()` is single-flight with one queued
- * follow-up, and a write landing mid-flush re-sets the dirty witness,
- * so its work is never lost — it belongs to the next flush.
- * 3. IT CANNOT SPIN. `flush()` on a clean brain returns without touching a
- * provider or scheduling I/O, so the second emit does no event-loop
- * work and the process exits. That is also why the listener is NOT
- * self-deregistered any more: a one-shot listener spent on a spurious
- * mid-script drain leaves the genuine end-of-script drain with nothing.
- * 4. A FAILED FLUSH IS SURVIVABLE AND LOUD. The write path is durable at
- * ack via the fact log; derived state is rebuildable. A throw is
- * reported per instance and the loop continues — exactly how
- * `kickBackgroundFlush()` already treats the same failure.
- *
- * The one thing lost against a closing handler is the clean-shutdown
- * marker for a script that opens a brain and never closes it: its next
- * open folds the log. That is the correct trade — a missing marker costs
- * a recovery fold, closing a live brain costs the caller its brain — and
- * the narration below names the cure.
- */
- const flushOnDrainedEventLoop = async () => {
- // A second emit can land on top of the first (this pass schedules async
- // work, the loop turns, the loop drains again). One pass at a time.
- if (Brainy.beforeExitFlushInFlight) return
-
- // Step aside for anyone whose close is running or done — the same
- // ownership rule the signal path follows.
- const live = [...Brainy.instances].filter(
- (instance) => instance.initialized && !instance.closed && instance._closeInFlight === null
- )
- if (live.length === 0) return
-
- // ONCE per registration cycle: a `console.log` to a pipe is itself
- // event-loop work, so narrating on every emit would keep the loop
- // turning and narrate forever.
- if (!Brainy.beforeExitNarrated) {
- Brainy.beforeExitNarrated = true
- console.log(
- `[Brainy] event loop drained with ${live.length} brain${live.length > 1 ? 's' : ''} ` +
- `open — persisting derived state; NOTHING was closed. A drained loop is not a ` +
- `shutdown: call close() (or send SIGTERM) when you mean one.`
- )
- }
-
- Brainy.beforeExitFlushInFlight = true
try {
- for (const instance of live) {
- try {
- await instance.flush()
- } catch (error) {
- // Per-instance isolation, and never fatal: canonical data is
- // durable at ack, so a failed derived-state flush costs the next
- // open a rebuild — it must not cost this one its brain.
- console.error(
- '[Brainy] flush on a drained event loop failed for one open brain ' +
- '(the brain stays open and usable; derived-state persistence retries at the ' +
- 'next flush, and canonical data is unaffected):',
- error
- )
+ let flushedCount = 0
+ for (const instance of Brainy.instances) {
+ if (instance.initialized) {
+ // Flush all buffered data, then close to release resources (timers, handles)
+ await Promise.all([
+ (async () => {
+ if (instance.storage && typeof instance.storage.flushCounts === 'function') {
+ await instance.storage.flushCounts()
+ }
+ })(),
+ (async () => {
+ if (instance.metadataIndex && typeof instance.metadataIndex.flush === 'function') {
+ await instance.metadataIndex.flush()
+ }
+ })(),
+ (async () => {
+ if (instance.graphIndex && typeof instance.graphIndex.flush === 'function') {
+ await instance.graphIndex.flush()
+ }
+ })(),
+ (async () => {
+ if (instance.index && typeof instance.index.flush === 'function') {
+ await instance.index.flush()
+ }
+ })()
+ ])
+ // Close components to stop timers that would prevent clean process exit
+ await Promise.all([
+ (async () => {
+ if (instance.graphIndex && typeof instance.graphIndex.close === 'function') {
+ await instance.graphIndex.close()
+ }
+ })(),
+ (async () => {
+ const index = instance.index as JsHnswVectorIndex & VectorIndexOptionalHooks
+ if (index && typeof index.close === 'function') {
+ await index.close()
+ }
+ })(),
+ (async () => {
+ const metadataIndex = instance.metadataIndex as MetadataIndexManager & MetadataIndexOptionalHooks
+ if (metadataIndex && typeof metadataIndex.close === 'function') {
+ await metadataIndex.close()
+ }
+ })(),
+ // Release the writer lock so a successor process can take over.
+ // No-op for readers and for backends without locking.
+ (async () => {
+ if (instance.storage && typeof instance.storage.releaseWriterLock === 'function') {
+ await instance.storage.releaseWriterLock()
+ }
+ })(),
+ // Stop the flush-request watcher to release its interval timer.
+ (async () => {
+ if (instance.storage && typeof instance.storage.stopFlushRequestWatcher === 'function') {
+ instance.storage.stopFlushRequestWatcher()
+ }
+ })(),
+ ])
+ flushedCount++
}
}
- } finally {
- Brainy.beforeExitFlushInFlight = false
+ if (flushedCount > 0) {
+ console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 1 ? 's' : ''})`)
+ }
+ } catch (error) {
+ console.error('Failed to flush on shutdown:', error)
}
}
@@ -2348,52 +1484,26 @@ export class Brainy implements BrainyInterface {
// kept as statics so the last live instance's close() can deregister them
// — the signal handles they hold are ref'd and would otherwise keep the
// process alive forever after every brain is closed.
- /**
- * Exit the process ONLY when Brainy is the sole handler for this signal.
- *
- * Registering a signal listener suppresses Node's default terminate
- * behaviour, so a library that attaches one must either exit or be sure
- * someone else will. Brainy attaching one AND exiting was the wrong half
- * of that choice for every host application with its own graceful
- * shutdown: both handlers run concurrently, and whichever finishes first
- * wins — a library flush finishing before an application's close()
- * terminated that close mid-flight, at exit code 0, with locks and
- * markers unwritten. When the host has its own handler (listener count
- * above our own), the host owns the exit; Brainy only makes its data
- * durable and steps aside.
- *
- * THE COUNT IS TAKEN WHEN THE SIGNAL ARRIVES, not after the shutdown ran.
- * "Is anyone else handling this signal?" is a question about the moment
- * the signal landed. Asking afterwards reads a process that has already
- * torn itself down: the handler now CLOSES its instances, and closing the
- * last brain deregisters Brainy's own listeners — so a host application's
- * single remaining listener would look like `<= 1` and get force-exited
- * out of its own graceful shutdown, precisely the failure above.
- *
- * SIGNALS ONLY — NEVER `beforeExit`. The reasoning above is entirely about
- * a signal Brainy has suppressed Node's default terminate behaviour for.
- * `beforeExit` suppresses nothing: Node exits by itself once the loop is
- * genuinely done, and the script that is still running when it fires is
- * not shutting down at all. Calling this from that path would end a live
- * script at exit code 0 mid-work. It is called from the two signal
- * listeners below and from nowhere else.
- */
- const exitIfSoleShutdownOwner = (ownersWhenSignalled: number): void => {
- if (ownersWhenSignalled <= 1) {
- process.exit(0)
- }
- }
Brainy.sigtermListener = async () => {
- const owners = process.listenerCount('SIGTERM')
- await closeOnShutdown()
- exitIfSoleShutdownOwner(owners)
+ await flushOnShutdown()
+ process.exit(0)
}
Brainy.sigintListener = async () => {
- const owners = process.listenerCount('SIGINT')
- await closeOnShutdown()
- exitIfSoleShutdownOwner(owners)
+ await flushOnShutdown()
+ process.exit(0)
+ }
+ Brainy.beforeExitListener = async () => {
+ // Self-deregister FIRST: Node re-emits 'beforeExit' after every event-
+ // loop drain, and this flush schedules new async work — with the
+ // listener still attached, a script that never calls close() would spin
+ // flush → drain → flush forever and never exit. One flush, then the
+ // next drain finds no listener and the process exits.
+ if (Brainy.beforeExitListener) {
+ process.off('beforeExit', Brainy.beforeExitListener)
+ Brainy.beforeExitListener = undefined
+ }
+ await flushOnShutdown()
}
- Brainy.beforeExitListener = flushOnDrainedEventLoop
process.on('SIGTERM', Brainy.sigtermListener)
process.on('SIGINT', Brainy.sigintListener)
process.on('beforeExit', Brainy.beforeExitListener)
@@ -2415,11 +1525,6 @@ export class Brainy implements BrainyInterface {
Brainy.sigtermListener = undefined
Brainy.sigintListener = undefined
Brainy.beforeExitListener = undefined
- // A later re-init is a fresh cycle: it may narrate its own drained-loop
- // notice, and no pass of the previous cycle can still be running (the last
- // close() drained the flush chain).
- Brainy.beforeExitNarrated = false
- Brainy.beforeExitFlushInFlight = false
Brainy.shutdownHooksRegisteredGlobally = false
}
@@ -2470,33 +1575,6 @@ export class Brainy implements BrainyInterface {
return this.initialized
}
- /**
- * @description Whether `close()` has BEGUN on this instance — in flight or
- * already finished. The question a shutdown owner asks: this brain's
- * teardown belongs to whoever started it, and a second party must not flush
- * its components or release its writer lock underneath it.
- *
- * True from the synchronous moment `close()` is entered, so a listener that
- * yields a tick and comes back reads the truth, not a stale "not yet".
- * @returns `true` once a close has started.
- */
- get isClosing(): boolean {
- return this._closeInFlight !== null
- }
-
- /**
- * @description Whether `close()` has FINISHED tearing this instance down —
- * durable steps attempted, writer lock released, instance terminal. A
- * closed brain never re-initializes; every operation on it throws.
- *
- * True after a close that FAILED partway, too: such a brain still holds no
- * writer lock and still serves nothing (see {@link close}).
- * @returns `true` once the teardown has completed.
- */
- get isClosed(): boolean {
- return this.closed
- }
-
/**
* Promise that resolves when Brainy is fully initialized and ready to use
*
@@ -2651,860 +1729,12 @@ export class Brainy implements BrainyInterface {
* deletes — the before-image + per-id-chain set.
* @param run - The single-op's existing operation batch builder (the
* `tx => {…}` body previously passed straight to `executeTransaction`).
- * @param precommit - Optional CAS precondition, run under the commit mutex.
- * @param pendingEvents - Change-feed events to stamp and emit post-commit.
- * @param records - Optional v2 marker records (e.g. the deferred-embedding
- * lifecycle markers) riding this write's commit fact — same generation,
- * one atomic append. Refused on generation-less bootstrap writes.
*/
- /**
- * Storage-root-relative prefix of the RETIRED sidecar pending-embed marker
- * files (pre-log builds persisted one raw object per pending embed here).
- * The markers live IN the generation log now (`embed.pending` /
- * `embed.landed` records); this prefix survives ONLY for the one-time
- * migration bridge ({@link bridgeLegacyPendingEmbedSidecars}) — no other
- * code path writes, lists, or deletes it.
- */
- private static readonly PENDING_EMBED_PREFIX = '_system/pending_embeds/'
-
- /**
- * Storage-root-relative path of the ADVISORY pending-embed low-water mark:
- * `{ generation, writtenAt }`, written whenever the pending set drains to
- * empty (and at clean close when empty). Every marker in facts at or below
- * `generation` is consumed, so recovery scans from `generation + 1`. The
- * mark is advisory and monotone-safe: stale-low costs a longer scan, never
- * a lost marker; it is never required for correctness.
- */
- private static readonly PENDING_EMBED_LOWWATER_PATH = '_system/pending_embeds_lowwater.json'
-
- /**
- * Storage-root-relative path of the pending-embed CHECKPOINT:
- * `{ generation, pending: string[], writtenAt }` — "as of durable generation
- * G the pending set was exactly this list". Open seeds the set from `pending`
- * and scans the log from `G + 1`, so the fold costs O(facts since G)
- * REGARDLESS of whether the set ever drains.
- *
- * WHY IT REPLACES THE EMPTY-ONLY MARK AS THE BOUND. The low-water mark
- * ({@link PENDING_EMBED_LOWWATER_PATH}) can only be written when the pending
- * set is EMPTY, because it carries no set — it means "everything at or below
- * G is consumed". A brain holding even ONE id that never lands (an embed that
- * keeps failing; a row reaped in memory only and re-folded every open) never
- * drains, so it never writes a mark, so the bound never engages on exactly
- * the brains whose fold is expensive: every open re-reads the whole log. The
- * checkpoint carries the set, so it needs no drain.
- *
- * The mark is still written and still read as the FALLBACK bound (a
- * checkpoint that is absent, torn, or malformed degrades to it, and then to
- * generation 1). Correctness over cost in every degradation: a stale or
- * missing checkpoint only lengthens the scan.
- */
- private static readonly PENDING_EMBED_CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json'
-
- /**
- * Checkpoint CADENCE BASE: attempt a checkpoint every N pending-set
- * transitions (enqueues + clears) while the brain is open, on top of the
- * drain-to-empty and clean-close writes. Hardcoded 90th-percentile default,
- * no knob, no timer: 64 transitions is far below the cost of the fold it
- * bounds and far above the per-write noise floor. An attempt that cannot
- * satisfy the durability law is SKIPPED, not forced — the next transition
- * retries.
- *
- * The interval ADAPTS to the one signal that matters, the backlog's own
- * size, because a checkpoint writes the WHOLE pending list: the interval is
- * `max(64, ceil(|pending| / 64))`, which holds the amortized cost of the
- * mechanism at ≤ 64 ids written per transition NO MATTER how large the
- * backlog grows. A term that scales with the store rather than with the
- * work is exactly the defect class this file is fixing; it must not be
- * reintroduced by the cure.
- */
- private static readonly PENDING_EMBED_CHECKPOINT_EVERY = 64
-
- /**
- * @description Mark a deferred embed pending (MT5): the id joins the
- * in-memory fast-path set and the returned `embed.pending` record is
- * threaded onto the deferred write's OWN commit fact — same generation,
- * same atomic append, and (in at-ack log durability) the same covering
- * fsync as the write itself. The marker can never be orphaned from its
- * write nor the write from its marker: a failed commit appends no fact,
- * so no durable marker exists either (the in-memory entry is harmless
- * and reaped by the worker). Recovery folds the marker back out of the
- * log at open ({@link recoverPendingEmbedsFromLog}).
- */
- private enqueuePendingEmbed(id: string): FactMarkerRecord {
- this._pendingEmbedIds.add(id)
- // Re-armed for real: any earlier in-memory-only clear is superseded.
- this._pendingEmbedUndurableClears.delete(id)
- this.noteEmbedCheckpointCadence()
- return { type: 'embed.pending', id, enqueuedAt: Date.now() }
- }
-
- /**
- * @description Clear a pending embed from the in-memory set. The DURABLE
- * clear is the `embed.landed` record riding the landing commit's own fact
- * (or, for a row deleted before its embed landed, the row's tombstone
- * fact) — the recovery fold consumes those; nothing here touches storage.
- * One honest residue: a pending row whose entity still exists but carries
- * no data is reaped in memory only, so it re-folds at the next open and
- * is re-reaped there — a bounded no-op, never a lost vector. That residue
- * is the ONLY `durability: 'in-memory-only'` caller, and the checkpoint
- * keeps carrying those ids so the bounded fold and a full fold from
- * generation 1 agree exactly (see {@link _pendingEmbedUndurableClears}).
- *
- * @param id - The pending id to clear.
- * @param durability - `'durable'` (default) when a record in the log at or
- * below the current head disarms this id (an `embed.landed` riding the
- * landing or unvector commit, or the row's tombstone — including the row
- * simply not being there any more); `'in-memory-only'` when nothing in the
- * log says so.
- */
- private clearPendingEmbed(
- id: string,
- durability: 'durable' | 'in-memory-only' = 'durable'
- ): void {
- this._pendingEmbedIds.delete(id)
- if (durability === 'in-memory-only') this._pendingEmbedUndurableClears.add(id)
- else this._pendingEmbedUndurableClears.delete(id)
- if (this._pendingEmbedIds.size === 0) this.maybeWriteEmbedLowWater()
- this.noteEmbedCheckpointCadence()
- }
-
- /**
- * @description Advance the advisory low-water mark: called at drain-to-empty
- * (and at clean close when empty), it records the fact log's CURRENT head —
- * with the set empty, every marker at or below the head has been consumed,
- * so the next open's recovery fold scans only what comes after. Fire-and-
- * forget at the drain (close() awaits the core); loud on failure: a missed
- * write costs the next open a longer scan, never a marker. No-op without a
- * fact log (no durable markers exist there) and on read-only opens.
- */
- private maybeWriteEmbedLowWater(): void {
- void this.writeEmbedLowWater()
- }
-
- /** The awaitable core of {@link maybeWriteEmbedLowWater} — close() awaits it. */
- private async writeEmbedLowWater(): Promise {
- if (this.isReadOnly) return
- const log = this.generationStore ? this.generationStore.getFactLog() : null
- if (!log) return
- const generation = log.headGeneration()
- if (!(generation > 0)) return
- try {
- await this.storage.writeRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH, {
- generation,
- writtenAt: Date.now()
- })
- } catch (err) {
- prodLog.warn(
- `[Brainy] pending-embed low-water write failed at generation ${generation}: ` +
- `${(err as Error).message} — the next open scans from the previous mark`
- )
- }
- }
-
- /**
- * @description Capture a pending-embed checkpoint, or refuse.
- *
- * THE DURABILITY LAW, satisfied by construction. The checkpoint asserts "as
- * of generation G the log's pending set was exactly this list", and the next
- * open TRUSTS it: it seeds the set and never reads a fact at or below G
- * again. So a checkpoint may only be taken at a G whose facts are DURABLE.
- * A checkpoint taken at head H while the facts up to H are still buffered
- * would be read back after a crash that truncated the tail — and an
- * `embed.landed` in a truncated fact would be gone from the log while the
- * checkpoint still recorded its id as landed. The row's landing vector went
- * with the truncated fact, so nothing would ever re-arm it: A LOST VECTOR.
- *
- * The gate is therefore `0 < head ≤ committed`. `committed` is the
- * generation manifest's watermark — the point the store's own recovery
- * treats as truth, and the point below which `FactLog.open()` never
- * truncates — and the group-commit flush fsyncs the log BEFORE advancing it
- * (see `GenerationStore.flushPendingSingleOps`). So every fact at or below
- * `head` is fsynced and survives the crash exactly as the checkpoint
- * describes it. Anything else (a head above the manifest, no log, no
- * generation yet, a read-only or closed brain) REFUSES: skipping a
- * checkpoint costs a longer scan next open, never a marker.
- *
- * The snapshot is taken SYNCHRONOUSLY with reading the two generations — no
- * `await` between them — so no commit and no worker step can slip between
- * "the generation I am about to claim" and "the set I claim for it".
- *
- * The one asymmetry, deliberately in the safe direction: an id whose
- * `embed.pending` record has not been appended yet (enqueued in memory, its
- * commit still in flight) is captured as pending at G although its marker
- * will land at G+1 or later. Over-stating pending costs one idempotent
- * re-embed attempt; under-stating it is the shape that loses a vector, and
- * cannot happen — every clear either rides a durable record at or below the
- * head, or is carried in {@link _pendingEmbedUndurableClears}.
- *
- * @returns The checkpoint payload, or `null` when this instant cannot host
- * one.
- */
- private captureEmbedCheckpoint(): { generation: number; pending: string[] } | null {
- if (this.isReadOnly || this.closed) return null
- const store = this.generationStore
- if (!store) return null
- const log = store.getFactLog()
- if (!log) return null
- // --- ONE SYNCHRONOUS INSTANT: no await until the return. ---
- const generation = log.headGeneration()
- const committed = store.committedGeneration()
- if (!(generation > 0) || generation > committed) return null
- const pending = new Set(this._pendingEmbedIds)
- for (const id of this._pendingEmbedUndurableClears) pending.add(id)
- // --- end of the synchronous instant. ---
- return { generation, pending: [...pending] }
- }
-
- /**
- * @description Fire-and-forget checkpoint write, single-flight: a burst of
- * transitions never stacks writes, and because each attempt captures
- * immediately before it writes, the file always ends up holding the most
- * recently captured (generation, set) PAIR — and every such pair is
- * independently true, so even an out-of-order landing is safe.
- * {@link closeDurableSteps} awaits the flight before taking the final one.
- */
- private maybeWriteEmbedCheckpoint(): void {
- if (this._pendingEmbedCheckpointFlight) return
- this._pendingEmbedCheckpointFlight = this.writeEmbedCheckpoint()
- .then((wrote) => {
- if (wrote) {
- this._pendingEmbedCheckpointDue = false
- this._pendingEmbedCheckpointTransitions = 0
- }
- })
- .finally(() => {
- this._pendingEmbedCheckpointFlight = null
- })
- }
-
- /**
- * The awaitable core of {@link maybeWriteEmbedCheckpoint}.
- * @returns `true` when a checkpoint was actually written.
- */
- private async writeEmbedCheckpoint(): Promise {
- const snapshot = this.captureEmbedCheckpoint()
- if (!snapshot) return false
- try {
- // Atomic on disk: the filesystem adapter's writeRawObject is tmp+rename
- // (see BaseStorage.writeRawObject), so a crash mid-write leaves either
- // the previous checkpoint or the new one — never a spliced file. And a
- // file that IS unreadable (a torn gzip, invalid JSON) throws typed on
- // read and degrades to the fallback bound; it can never parse into a
- // partial `pending` list.
- //
- // The file is NOT separately fsynced, and does not need to be: losing
- // the rename to a power cut leaves the PREVIOUS checkpoint (or none),
- // which only lengthens the next scan. The invariant that matters is the
- // other direction — a checkpoint that IS visible names a generation
- // whose facts are durable — and that is established by the capture gate
- // above, not by this write.
- await this.storage.writeRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH, {
- generation: snapshot.generation,
- pending: snapshot.pending,
- writtenAt: Date.now()
- })
- return true
- } catch (err) {
- prodLog.warn(
- `[Brainy] pending-embed checkpoint write failed at generation ` +
- `${snapshot.generation}: ${(err as Error).message} — the next open scans ` +
- `from the previous checkpoint`
- )
- return false
- }
- }
-
- /**
- * @description The checkpoint cadence tick: count one pending-set transition
- * and OWE a checkpoint every {@link PENDING_EMBED_CHECKPOINT_EVERY}
- * transitions, plus on every drain to empty. The debt stays armed across
- * attempts the durability law refuses — during a write burst the log head
- * legitimately runs ahead of the manifest, so the first attempt often cannot
- * be taken — and the next transition retries it. An active brain therefore
- * checkpoints steadily without ever forcing a flush; an idle one relies on
- * its clean close. No timer is involved, so nothing survives close().
- */
- private noteEmbedCheckpointCadence(): void {
- if (this.isReadOnly || this.closed) return
- this._pendingEmbedCheckpointTransitions++
- const listed = this._pendingEmbedIds.size + this._pendingEmbedUndurableClears.size
- const every = Math.max(
- Brainy.PENDING_EMBED_CHECKPOINT_EVERY,
- Math.ceil(listed / Brainy.PENDING_EMBED_CHECKPOINT_EVERY)
- )
- if (
- this._pendingEmbedIds.size === 0 ||
- this._pendingEmbedCheckpointTransitions >= every
- ) {
- this._pendingEmbedCheckpointDue = true
- }
- if (this._pendingEmbedCheckpointDue) this.maybeWriteEmbedCheckpoint()
- }
-
- /**
- * @description Resolve the pending-embed fold's BOUND: the checkpoint first
- * (a set plus a generation), then the legacy low-water mark (a generation
- * only), then genesis. Every degradation is loud and lengthens the scan
- * rather than shortening it — a bound that could skip a marker is never
- * derived from a value this method could not fully validate.
- * @returns The bound's name, the first generation to scan, and the ids to
- * seed the pending set with.
- */
- private async readPendingEmbedBound(): Promise<{
- bound: 'checkpoint' | 'low-water' | 'genesis'
- fromGeneration: number
- seeded: string[]
- }> {
- let checkpointRejected: string | null = null
- try {
- const raw = await this.storage.readRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH)
- if (raw !== null && raw !== undefined) {
- const parsed = Brainy.parsePendingEmbedCheckpoint(raw)
- if (parsed) {
- return {
- bound: 'checkpoint',
- fromGeneration: parsed.generation + 1,
- seeded: parsed.pending
- }
- }
- checkpointRejected = 'its shape is not { generation: number > 0, pending: string[] }'
- }
- } catch (err) {
- // A real storage fault (EIO/EACCES/…). Corruption never lands here: the
- // adapter maps a torn raw object to `null` AFTER logging it as a
- // production error, so a torn checkpoint arrives as "absent" — loud at
- // the adapter, and bounded here by the fallback below.
- checkpointRejected = `reading it failed: ${(err as Error).message}`
- }
- if (checkpointRejected !== null) {
- prodLog.warn(
- `[Brainy] pending-embed checkpoint REFUSED (${checkpointRejected}) — falling back ` +
- `to the low-water mark, else a full fold from generation 1`
- )
- }
-
- try {
- const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as {
- generation?: number
- } | null
- if (mark && typeof mark.generation === 'number' && mark.generation > 0) {
- return { bound: 'low-water', fromGeneration: mark.generation + 1, seeded: [] }
- }
- } catch {
- // No mark (or unreadable): scan from 1 — correctness over cost.
- }
- return { bound: 'genesis', fromGeneration: 1, seeded: [] }
- }
-
- /**
- * @description Validate a raw checkpoint object STRICTLY. Anything that is
- * not exactly `{ generation: integer > 0, pending: string[] }` is refused
- * whole — a partially-usable checkpoint is the one shape that could seed a
- * short pending set behind a high bound, which is how a vector is lost.
- * @param raw - The object read back from storage.
- * @returns The validated checkpoint, or `null`.
- */
- private static parsePendingEmbedCheckpoint(
- raw: unknown
- ): { generation: number; pending: string[] } | null {
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
- const { generation, pending } = raw as { generation?: unknown; pending?: unknown }
- if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= 0) {
- return null
- }
- if (!Array.isArray(pending) || pending.some((id) => typeof id !== 'string' || id === '')) {
- return null
- }
- return { generation, pending: pending as string[] }
- }
-
- /**
- * @description Rebuild the pending-embed set by REPLAYING the generation
- * log's marker records (recovery = replay, not listing): `embed.pending`
- * arms an id, `embed.landed` disarms it, and a noun tombstone disarms it
- * too (a row deleted before its embed landed owes no vector). What
- * survives the fold is exactly the set of acknowledged deferred writes
- * whose vectors have not landed.
- *
- * BOUND: the scan starts after the pending-embed CHECKPOINT
- * ({@link Brainy.PENDING_EMBED_CHECKPOINT_PATH}) — "as of durable generation
- * G the pending set was exactly this list" — so the fold seeds the set from
- * that list and reads only the facts after G. O(delta) whether or not the
- * set ever drains, which is the whole point: the previous bound, the
- * empty-only low-water mark, could not be written at all by a brain holding
- * one id that never lands, so those brains re-read their whole log at every
- * open. The mark remains the FALLBACK bound (checkpoint absent, torn, or
- * malformed), and generation 1 the fallback below that — a brain opened for
- * the first time after this change has neither a checkpoint nor, if it never
- * drained, a mark, so it pays one full fold and writes a checkpoint on the
- * way out. A stale bound costs a longer scan, never a marker. The fold stays
- * on the open's foreground — the crash-recovery contract pins that a
- * reopened brain has its markers re-armed when open() returns — and the
- * bound is what makes that cheap. What it did (bound, start, facts read) is
- * narrated and kept in {@link _pendingEmbedFoldReport}.
- * It is SKIPPED WHOLESALE when the log has never had a v2 tail
- * ({@link FactLog.hasV2History} — v1 facts cannot carry marker records),
- * so pre-cutover brains pay nothing; on a mixed log the scan still reads
- * the v1 segments (a segment's format is only known from its bytes) but
- * they fold to nothing, so the DECODE cost is bounded by v2 history.
- * Storage without a fact log hosts no durable markers at all — the
- * pending set is session-local there, matching that storage's overall
- * durability posture.
- */
- private async recoverPendingEmbedsFromLog(): Promise {
- const log = this.generationStore.getFactLog()
- if (!log || !log.hasV2History()) return
- const { bound, fromGeneration, seeded } = await this.readPendingEmbedBound()
- for (const id of seeded) this._pendingEmbedIds.add(id)
- let factsScanned = 0
- const scan = log.scanFacts({ fromGeneration })
- for await (const batch of scan.batches()) {
- for (const fact of batch.facts) {
- factsScanned++
- for (const record of fact.records ?? []) {
- if (record.type === 'embed.pending') {
- this._pendingEmbedIds.add(record.id)
- } else if (record.type === 'embed.landed') {
- this._pendingEmbedIds.delete(record.id)
- }
- }
- for (const op of fact.ops) {
- if (op.kind === 'noun' && op.record === null) {
- this._pendingEmbedIds.delete(op.id)
- }
- }
- }
- }
- this._pendingEmbedFoldReport = {
- bound,
- fromGeneration,
- factsScanned,
- seeded: seeded.length,
- pending: this._pendingEmbedIds.size
- }
- // The narration channel: an operator is entitled to hear which bound
- // applied and what it cost, on every open — that is how a bound that
- // silently stopped engaging (the defect this replaced) becomes visible.
- prodLog.narrate(
- `[Brainy] pending-embed fold: ${bound} bound → scanned ${factsScanned} fact(s) ` +
- `from generation ${fromGeneration}, seeded ${seeded.length} id(s), ` +
- `${this._pendingEmbedIds.size} pending`
- )
- }
-
- /**
- * @description ONE-TIME LEGACY BRIDGE: a brain that deferred embeds under
- * a pre-log build persisted one sidecar marker file per pending embed
- * under {@link PENDING_EMBED_PREFIX}. At open, fold those ids into the
- * pending set AND migrate them: commit ONE fact carrying their
- * `embed.pending` records (the log is the markers' durable home now),
- * then delete the sidecar files — in that order, so a crash between the
- * two re-runs the bridge instead of losing a marker (a re-migrated
- * duplicate folds idempotently; at worst an already-landed embed re-runs
- * once — idempotent, never lost). Narrated loudly. Storage without a
- * fact log keeps its sidecars in place (there is no log to migrate into)
- * and folds them into memory only, exactly as loud.
- */
- private async bridgeLegacyPendingEmbedSidecars(): Promise {
- const markerPaths = await this.storage.listRawObjects(Brainy.PENDING_EMBED_PREFIX)
- if (markerPaths.length === 0) return
- const ids: string[] = []
- for (const path of markerPaths) {
- const id = path.slice(path.lastIndexOf('/') + 1)
- if (id) ids.push(id)
- }
- if (ids.length === 0) return
- for (const id of ids) this._pendingEmbedIds.add(id)
- if (!this.generationStore.getFactLog()) {
- prodLog.warn(
- `[Brainy] ${ids.length} legacy pending-embed sidecar marker(s) found, but this ` +
- `storage hosts no fact log to migrate them into — folded into memory; the ` +
- `sidecar files remain the durable recovery source on this configuration`
- )
- return
- }
- const enqueuedAt = Date.now()
- const markers: FactMarkerRecord[] = ids.map((id) => ({
- type: 'embed.pending',
- id,
- enqueuedAt
- }))
- // One migration commit: a zero-op fact carrying every legacy marker
- // (empty-ops facts are legal; the records leg makes this one visible).
- await this.generationStore.commitSingleOp({
- touched: {},
- records: markers,
- execute: async () => {}
- })
- for (const id of ids) {
- await this.storage.deleteRawObject(`${Brainy.PENDING_EMBED_PREFIX}${id}`).catch(() => {})
- }
- prodLog.info(
- `[Brainy] migrated ${ids.length} legacy pending-embed sidecar marker(s) into the ` +
- `generation log and removed the sidecar files (one-time bridge)`
- )
- }
-
- /**
- * @description Start (or skip into) the ONE deferred-embedding worker.
- * Never awaited by write paths; failures are LOUD and markers survive for
- * the next kick (next deferred write, or the next open's recovery).
- */
- private kickEmbedWorker(): void {
- if (this._embedWorkerFlight || this._pendingEmbedIds.size === 0 || this.isReadOnly) return
- this._embedWorkerFlight = this.runEmbedWorker()
- .catch((err) => {
- prodLog.error(
- `[Brainy] deferred-embed worker failed: ${(err as Error).message} — ` +
- `markers retained; retries at the next deferred write or open`
- )
- })
- .finally(() => {
- this._embedWorkerFlight = null
- if (this._pendingEmbedIds.size > 0) {
- // New arrivals during the run: schedule (never recurse) the next pass.
- const t = setTimeout(() => this.kickEmbedWorker(), 0)
- ;(t as { unref?: () => void }).unref?.()
- }
- })
- }
-
- /**
- * @description Drain the pending-embed set: embed each row's CURRENT data
- * (a row updated again before its turn embeds the latest content — the
- * marker set is idempotent per id) and swap the vector in ATOMICALLY
- * (ReplaceInVectorIndex → the in-place update; the row is never absent
- * from search). Orphans (row deleted, or no data) reap their markers.
- */
- private async runEmbedWorker(): Promise {
- const batch = Array.from(this._pendingEmbedIds)
- for (const id of batch) {
- try {
- const entity = await this.get(id, { includeVectors: true })
- if (!entity) {
- // The row is GONE. Either it was deleted — its tombstone fact
- // durably disarms the marker, at or below the head, exactly as the
- // fold reads it — or its create never became durable, in which case
- // the log carries no `embed.pending` for it either. Both are durable
- // clears: a full fold from generation 1 reaches the same answer.
- this.clearPendingEmbed(id, 'durable')
- continue
- }
- if (entity.data === undefined || entity.data === null) {
- // Orphan reap, IN MEMORY ONLY: a data-less-but-present row (edge
- // case) has nothing to embed, but no record in the log says so, so
- // the fold would re-arm it. Cleared here and carried in the
- // checkpoint (see clearPendingEmbed) — it re-folds and re-reaps at
- // the next open exactly as before: bounded, never a lost vector,
- // and never a checkpoint that disagrees with the log.
- this.clearPendingEmbed(id, 'in-memory-only')
- continue
- }
- // Hang guard: a wedged embedder must not block every later pending
- // embed forever — time out LOUDLY, keep the marker, move on. (A
- // failure is retryable; an unbounded silent wait is the outlawed
- // shape.)
- const newVector = await Promise.race([
- this.embed(entity.data),
- new Promise((_, reject) => {
- const t = setTimeout(
- () => reject(new Error('deferred embed timed out after 60s')),
- 60_000
- )
- ;(t as { unref?: () => void }).unref?.()
- })
- ])
- if (!this.dimensions) {
- this.dimensions = newVector.length
- } else if (newVector.length !== this.dimensions) {
- throw new Error(
- `deferred embed produced ${newVector.length} dimensions, store expects ${this.dimensions}`
- )
- }
- const oldVector = (entity.vector as number[] | undefined) ?? []
- // The landing commit's fact carries the embed.landed record (vector
- // inline, per the v2 format) alongside the row's after-image — the
- // durable "this pending is consumed" that recovery's fold reads.
- await this.persistSingleOp(
- { nouns: [id] },
- async (tx) => {
- tx.addOperation(
- new SaveNounOperation(this.storage, {
- id,
- vector: newVector,
- connections: new Map(),
- level: 0
- })
- )
- tx.addOperation(
- new ReplaceInVectorIndexOperation(this.index, id, oldVector, newVector, this.indexWriteGeneration)
- )
- },
- undefined,
- undefined,
- [{ type: 'embed.landed', id, vector: newVector }],
- 'system:embed-landing'
- )
- // Vectored-noun ledger: the landing commit above carries a vector
- // write with NO accompanying metadata operation, so the
- // saveNounMetadata(..., hasVector) seam never fires for it — the
- // narrow storage hook is the only seam left. `oldVector.length===0`
- // (already known for free from the pre-embed read above) proves this
- // is a GENUINE first landing, not a re-embed of an already-vectored
- // row (e.g. a deferred update() on a row that already had a real
- // vector) — the latter must never double-count.
- if (oldVector.length === 0) {
- await this.storage.noteVectorLanded?.(id)
- }
- this.clearPendingEmbed(id)
- } catch (err) {
- prodLog.warn(
- `[Brainy] deferred embed for ${id} failed: ${(err as Error).message} — marker retained for retry`
- )
- }
- }
- }
-
- /**
- * @description The deferred-embedding BARRIER: resolves when every pending
- * embed has landed (vector searchable) or been reaped. The eventual-
- * vector-index contract's awaitable edge — tests and "must be searchable
- * before I proceed" callers use this; nothing else ever needs to wait.
- */
- public async awaitPendingEmbeds(): Promise {
- while (this._pendingEmbedIds.size > 0 || this._embedWorkerFlight) {
- this.kickEmbedWorker()
- await (this._embedWorkerFlight ?? Promise.resolve())
- }
- }
-
- /** The deferred-embedding backlog size (also on getIndexStatus().pendingEmbeds). */
- public pendingEmbedCount(): number {
- return this._pendingEmbedIds.size
- }
-
- /**
- * THE READ BARRIER: wait until a projection — or every projection — has
- * caught up to the CURRENT committed head, so a write-then-recall caller
- * has ONE honest await instead of a sleep-and-hope.
- *
- * Legs:
- * - `'semantic'` — waits for the deferred-embedding backlog to drain
- * (delegates to {@link awaitPendingEmbeds}, which keeps working
- * unchanged as this leg's engine). After it resolves, every previously
- * acknowledged write is vector-searchable.
- * - `'metadata'` / `'graph'` / `'aggregation'` — resolve IMMEDIATELY by
- * design today: these projections are updated inside the write path, so
- * by the time a write's promise resolves they already reflect it. Their
- * asynchrony arrives with the log-authority read path; the door's shape
- * freezes now so callers written against it keep working unchanged when
- * those legs become real waits.
- * - no argument — every projection at the head; today that reduces to the
- * semantic drain (the only asynchronous projection in the current
- * architecture).
- *
- * `opts.generation`: resolve as soon as the projection's watermark has
- * reached that committed generation. The pending-embed set carries no
- * generation stamps today, so the refinement is conservative — an empty
- * backlog resolves immediately (the watermark is at the head, hence ≥ any
- * committed generation); a non-empty backlog waits for the full drain, a
- * SUPERSET of the requested wait, never a partial one.
- *
- * `opts.timeoutMs`: on expiry the promise REJECTS with
- * {@link WaitForIndexedTimeoutError} — typed, carrying the leg and the
- * still-pending embed count, and naming the gauge to check
- * (`getIndexStatus().projections.semantic.pendingEmbeds`). Never a silent
- * partial wait: a timeout means the projection has NOT caught up.
- *
- * @example Write, then semantically recall — no polling, no sleeps
- * ```typescript
- * const id = await brain.add({
- * data: 'quarterly revenue narrative',
- * type: NounType.Document,
- * deferEmbedding: true,
- * metadata: { kind: 'report' }
- * })
- * await brain.waitForIndexed('semantic') // the barrier: vector landed + indexed
- * const hits = await brain.find({ query: 'revenue report', searchMode: 'semantic' })
- * // `id` is eligible to appear in `hits` — the recall is honest, not lucky.
- * ```
- *
- * @param path - The projection to wait on; omit to wait on all of them.
- * @param opts - Optional `generation` watermark target and `timeoutMs` bound.
- * @throws {WaitForIndexedTimeoutError} When `timeoutMs` expires before the
- * projection catches up.
- */
- public async waitForIndexed(
- path?: IndexedProjectionPath,
- opts?: WaitForIndexedOptions
- ): Promise {
- await this.ensureInitialized()
-
- // Synchronous projections: updated inside the write path today, so an
- // acknowledged write is already reflected — resolve immediately BY
- // DESIGN (honest, not a stub). When the log-authority read path makes
- // these legs asynchronous, only this body changes; the door's shape is
- // frozen now.
- if (path === 'metadata' || path === 'graph' || path === 'aggregation') {
- return
- }
-
- // 'semantic' — or no-arg, which today reduces to it: the deferred-embed
- // backlog is the only asynchronous projection in the current
- // architecture.
-
- // Generation refinement (conservative — see JSDoc): an empty backlog
- // means the semantic watermark is at the head, hence ≥ any committed G.
- if (opts?.generation !== undefined && this._pendingEmbedIds.size === 0) {
- return
- }
-
- const timeoutMs = opts?.timeoutMs
- const drained = this.awaitPendingEmbeds()
- if (timeoutMs === undefined) {
- return drained
- }
-
- // Typed timeout: reject LOUDLY with the leg + the live backlog gauge.
- // (`drained` never rejects — the worker catches its own failures — so
- // abandoning it on timeout cannot leak an unhandled rejection; the
- // backlog keeps draining in the background.)
- let timer: ReturnType | undefined
- try {
- await Promise.race([
- drained,
- new Promise((_, reject) => {
- timer = setTimeout(
- () =>
- reject(
- new WaitForIndexedTimeoutError(
- path ?? 'all',
- timeoutMs,
- this._pendingEmbedIds.size
- )
- ),
- timeoutMs
- )
- ;(timer as { unref?: () => void }).unref?.()
- })
- ])
- } finally {
- if (timer !== undefined) clearTimeout(timer)
- }
- }
-
- /**
- * @description The write-side persistence trigger (policy `'auto'`): count
- * the committed write, kick a single-flight BACKGROUND flush when the
- * write-count or interval threshold is crossed, and (re)arm the idle
- * timer. Never awaited by the write path — the ack is already durable at
- * the canonical layer; this schedules DERIVED-state persistence on the
- * engine's own cadence (callers never call flush() in hot paths).
- */
- private noteWriteForPersistence(): void {
- // THE DIRTY WITNESS. Set on every committed write — both commit paths
- // (single-op and transaction) end here, and the deferred-embed worker
- // lands its vectors through the single-op path — BEFORE the policy check,
- // so a `'manual'` consumer's explicit flush() is never skipped either.
- // Cleared by a flush that actually runs; see flush().
- this._dirtySinceLastFlush = true
- const cfg = this.config.persistence
- if (this.isReadOnly || cfg?.policy === 'manual') return
- this._persistDirtyWrites++
- const every = cfg?.flushEveryWrites ?? 512
- const intervalMs = cfg?.flushIntervalMs ?? 30_000
- const idleMs = cfg?.flushOnIdleMs ?? 2_000
-
- if (
- this._persistDirtyWrites >= every ||
- Date.now() - this._persistLastFlushAt >= intervalMs
- ) {
- this.kickBackgroundFlush('threshold')
- }
-
- if (this._persistIdleTimer) clearTimeout(this._persistIdleTimer)
- this.armIdleFlushTimer(idleMs, intervalMs)
- }
-
- /**
- * @description Arm the idle-flush timer — DEBOUNCED UNDER LOAD. The idle
- * trigger exists to make a QUIET system durable fast; it must never add
- * flush pressure to a BUSY one. When individual writes are slower than
- * the idle window (a contended disk), every inter-write gap looks like
- * "idle" and would fire a full flush per write — a measured 15-flush
- * amplifier during 100 contended adds on a production-shaped box. The
- * law: an idle fire landing within `intervalMs` of the last flush DEFERS
- * (re-arms for the remaining interval) rather than flushing — deferred,
- * never dropped, so a lone write on a then-quiet system still persists at
- * the interval boundary without any further write arriving; a genuinely
- * quiet system (last flush long past) flushes on idle exactly as before.
- */
- private armIdleFlushTimer(idleMs: number, intervalMs: number, delayMs = idleMs): void {
- // The idle-fire spacing floor: 10× the CONFIGURED idle window, capped by
- // the interval — always derived from idleMs, never from a deferred
- // re-arm delay (recomputing from the delay compounds into runaway
- // deferral). Scales with intent — a caller configuring a tiny idle
- // window gets fast idle-driven durability (small floor); default config
- // (2s idle / 30s interval) gets a 20s floor, capping the contended-disk
- // shape at ~1 idle flush per 20s instead of one per inter-write gap.
- const floorMs = Math.min(intervalMs, idleMs * 10)
- const timer = setTimeout(() => {
- this._persistIdleTimer = null
- if (this._persistDirtyWrites === 0) return
- const sinceFlush = Date.now() - this._persistLastFlushAt
- if (sinceFlush >= floorMs) {
- this.kickBackgroundFlush('idle')
- } else {
- // Deferred, never dropped: land exactly at the floor boundary.
- this.armIdleFlushTimer(idleMs, intervalMs, Math.max(idleMs, floorMs - sinceFlush))
- }
- }, delayMs)
- // Never hold the process open for a cadence timer.
- ;(timer as { unref?: () => void }).unref?.()
- this._persistIdleTimer = timer
- }
-
- /**
- * @description Start (or join) the ONE background flush. The dirty counter
- * resets at kick time so writes landing during the flush re-accumulate
- * toward the next trigger. A failure is LOUD and leaves the writes counted
- * again — silence is not an option, and neither is a retry storm (the next
- * trigger re-attempts).
- *
- * COALESCING LIVES IN {@link flush}, NOT HERE. A kick that arrives while a
- * flush is running used to return without doing anything — the writes it
- * counted waited for some LATER trigger, and this method's guard also could
- * not coalesce the flushes it does not start (the cross-process
- * flush-request watcher and application `flush()` calls both go straight to
- * `flush()`; two of those overlapping is exactly what production showed).
- * The gate in `flush()` covers every caller: this kick now either runs the
- * flush or joins the single queued follow-up, so the writes it counted are
- * always someone's work, and there is still never a second concurrent run.
- */
- private kickBackgroundFlush(reason: 'threshold' | 'idle'): void {
- const counted = this._persistDirtyWrites
- this._persistDirtyWrites = 0
- this._persistLastFlushAt = Date.now()
- this._persistBackgroundFlight = this.flush()
- .catch((err) => {
- this._persistDirtyWrites += counted // re-arm the trigger honestly
- prodLog.error(
- `[Brainy] background flush (${reason}) FAILED: ${(err as Error).message} — ` +
- `derived-state persistence retries at the next trigger; canonical data is unaffected`
- )
- })
- .finally(() => {
- this._persistBackgroundFlight = null
- })
- }
-
private async persistSingleOp(
touched: { nouns?: string[]; verbs?: string[] },
run: TransactionFunction,
precommit?: (before: CommitBeforeImages) => void,
- pendingEvents?: PendingChangeEvent[],
- records?: FactMarkerRecord[],
- origin?: string
+ pendingEvents?: PendingChangeEvent[]
): Promise<{ generation?: number; timestamp: number; degraded?: string[] }> {
// Change-feed capture: when this write will emit, hold a reference to the
// commit's before-images so `remove` events can carry the record's last
@@ -3519,15 +1749,6 @@ export class Brainy implements BrainyInterface {
: precommit
if (!this._generationStampingActive) {
- // Marker records ride a commit FACT — a generation-less bootstrap
- // write has none to ride. No bootstrap path defers embeds today;
- // refuse loudly rather than silently dropping a durable marker.
- if (records && records.length > 0) {
- throw new Error(
- 'persistSingleOp: marker records require a generation-stamped commit — ' +
- 'a bootstrap (generation-0) write cannot carry them'
- )
- }
// Init-time / infrastructure baseline write (e.g. the VFS root): apply
// WITHOUT creating a generation. Generation 0 is the freshly-materialized
// brain (bootstrap included); the first USER write is generation 1.
@@ -3550,9 +1771,7 @@ export class Brainy implements BrainyInterface {
await this.generationStore.runWithoutGeneration(() =>
this.transactionManager.executeTransaction(run, {
timeout: transactTimeoutBudget(
- (touched.nouns?.length ?? 0) + (touched.verbs?.length ?? 0),
- undefined,
- this.config.transactionBudgetFloorMs
+ (touched.nouns?.length ?? 0) + (touched.verbs?.length ?? 0)
)
})
)
@@ -3566,14 +1785,10 @@ export class Brainy implements BrainyInterface {
receipt = await this.generationStore.commitSingleOp({
touched,
precommit: captureAndCheck,
- ...(records && records.length > 0 ? { records } : {}),
- ...(origin ? { origin } : {}),
execute: () =>
this.transactionManager.executeTransaction(run, {
timeout: transactTimeoutBudget(
- (touched.nouns?.length ?? 0) + (touched.verbs?.length ?? 0),
- undefined,
- this.config.transactionBudgetFloorMs
+ (touched.nouns?.length ?? 0) + (touched.verbs?.length ?? 0)
)
})
})
@@ -3603,7 +1818,6 @@ export class Brainy implements BrainyInterface {
)
}
}
- this.noteWriteForPersistence()
return receipt
}
@@ -3669,29 +1883,6 @@ export class Brainy implements BrainyInterface {
}
}
- /**
- * @description Build the AGGREGATION view of an entity from a stored flat
- * metadata record — EVERY reserved field mapped to its top-level entity
- * name (stored `noun` → `type`), custom metadata in `metadata`. This must
- * mirror the add-path `entityForIndexing` shape exactly: the aggregation
- * engine resolves groupBy/where fields via `resolveEntityField`
- * (top-level standard fields + custom metadata), so a view that drops a
- * reserved field makes every aggregate grouped by that field decrement a
- * group that does not exist — counts then drift upward forever after
- * deletes (SELF-AGGREGATE-DELETE-DRIFT). Do not hand-roll subsets of this.
- * @param record - The stored flat metadata record (before-image or pre-delete read).
- * @returns The full-fidelity entity view for aggregation hooks.
- */
- private entityForAggFromRawRecord(record: Record): Record {
- const { reserved, custom } = splitNounMetadataRecord(record)
- const { noun, ...rest } = reserved
- return {
- type: noun,
- ...rest,
- metadata: custom
- }
- }
-
/**
* @description Add an entity (noun) to the brain. Embeds `data` into a vector and
* indexes the entity across all three intelligences — vector similarity, graph
@@ -3719,6 +1910,12 @@ export class Brainy implements BrainyInterface {
// Zero-config validation (static import for performance)
validateAddParams(params)
+ // Reserved fields arriving via the metadata bag (untyped callers — the
+ // compile-time guard stops TypeScript callers) are normalized to their
+ // canonical top-level location BEFORE any enforcement runs, so a
+ // remapped subtype participates in subtype-pairing enforcement and the
+ // indexed metadata bag carries only custom fields.
+ params = this.remapReservedAddMetadata(params)
// Tracked-field vocabulary enforcement (Layer 2). Walks both bags so a
// tracked field declared at top level (e.g. 'subtype') and one declared in
@@ -3779,98 +1976,50 @@ export class Brainy implements BrainyInterface {
}
// Get or compute vector
- // MT5 deferred embedding: ack at durability with a stub vector and a
- // pending marker riding the insert's OWN commit fact (same generation,
- // one atomic append — a marker-less committed row, the silently-missing-
- // vector shape, is structurally impossible). The background worker
- // embeds + inserts.
- const deferringEmbed = params.deferEmbedding === true && !params.vector
- let vector = deferringEmbed
- ? []
- : params.vector || (await this.embed(params.data))
+ const vector = params.vector || (await this.embed(params.data))
- // THE ZERO-NORM LAW (canonical write side): a zero-norm vector is not a
- // vector — it never crosses an engine boundary (the engine pair's seam
- // law). This engine's own cosine distance treats an all-zero vector
- // safely (a zero-norm operand always scores MAXIMUM distance — see
- // isZeroNormVector's JSDoc), but a downstream engine serving squared-
- // euclidean distance cannot tell it apart from a legitimate origin
- // point — a false attractor that silently darkened 150+ rows in a
- // production deployment. The index belt (AddToVectorIndexOperation)
- // already refuses to INDEX a zero-norm vector, but until now the
- // CANONICAL write still persisted it and the vectored-noun ledger
- // counted it — so a near-empty store whose only vectored row was
- // zero-norm read "canonical vectored > 0, index size 0" and threw a
- // not-ready error at open. Normalize HERE, before the dimension pin,
- // the vectored-ledger flag (`SaveNounMetadataOperation`'s `hasVector`),
- // and the index ops below ever see it, so it persists as the sanctioned
- // "unvectored" `[]` shape instead — the canonical write still succeeds.
- if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) {
- prodLog.warn(
- `[Brainy] add(): entity ${id} was given an explicit all-zero vector — ` +
- `a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
+ // Ensure dimensions are set
+ if (!this.dimensions) {
+ this.dimensions = vector.length
+ } else if (vector.length !== this.dimensions) {
+ throw new Error(
+ `Vector dimension mismatch: expected ${this.dimensions}, got ${vector.length}`
)
- vector = []
}
- // Ensure dimensions are set (a deferred-embed stub carries no dimension
- // information — the worker's real vector goes through the same guard).
- // Gated on `vector.length > 0`, not `!deferringEmbed`: ANY insert whose
- // vector is the "unvectored" empty-array shape carries no dimension
- // information, deferred or not — an explicit `vector: []` (e.g. the VFS
- // root's zero-norm fix, see VirtualFileSystem.doInitializeRoot()) must
- // never pin `this.dimensions` to 0, which would poison every subsequent
- // real embed's dimension check for the life of the store.
- if (!deferringEmbed && vector.length > 0) {
- if (!this.dimensions) {
- this.dimensions = vector.length
- } else if (vector.length !== this.dimensions) {
- throw new Error(
- `Vector dimension mismatch: expected ${this.dimensions}, got ${vector.length}`
- )
+ // Prepare metadata for storage
+ // data is stored opaquely in the 'data' field - NOT spread into top-level metadata.
+ // Only metadata fields are queryable via find({ where }).
+ const storageMetadata = {
+ ...params.metadata,
+ // Preserve the caller's original (non-UUID) id when normalized, so reads
+ // can surface it. A real UUID passes through with no _originalId.
+ ...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }),
+ data: params.data,
+ noun: params.type,
+ ...(params.subtype !== undefined && { subtype: params.subtype }),
+ // visibility: stored only when not 'public' (absent === public, keeps records lean)
+ ...(params.visibility !== undefined &&
+ params.visibility !== 'public' && { visibility: params.visibility }),
+ service: params.service,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ _rev: 1,
+ ...(params.confidence !== undefined && { confidence: params.confidence }),
+ ...(params.weight !== undefined && { weight: params.weight }),
+ ...(params.createdBy && { createdBy: params.createdBy })
}
- }
-
- // Prepare metadata for storage: a v2 nested-bag record — engine fields
- // top-level, the user's bag nested VERBATIM (any name, including engine
- // spellings like `confidence` or `type`, is the user's and survives
- // faithfully; the field-addressing law).
- const storageMetadata = buildNounMetadataRecord(
- {
- data: params.data,
- noun: params.type,
- ...(params.subtype !== undefined && { subtype: params.subtype }),
- // visibility: stored only when not 'public' (absent === public, keeps records lean)
- ...(params.visibility !== undefined &&
- params.visibility !== 'public' && { visibility: params.visibility }),
- service: params.service,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- _rev: 1,
- ...(params.confidence !== undefined && { confidence: params.confidence }),
- ...(params.weight !== undefined && { weight: params.weight }),
- ...(params.createdBy && { createdBy: params.createdBy })
- },
- {
- ...params.metadata,
- // Preserve the caller's original (non-UUID) id when normalized, so reads
- // can surface it. A real UUID passes through with no _originalId.
- ...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId })
- }
- )
// Build entity structure for indexing (NEW - with top-level fields)
// Optional fields must use conditional spreading to match storageMetadata exactly.
// If undefined values are included as explicit keys, extractIndexableFields indexes
// them as '__NULL__' entries that removeFromIndex can never clean up (storageMetadata
// omits those keys entirely via conditional spreading, so the fields don't match).
- // No `level` here: engine plumbing never enters the indexing view — a
- // hardcoded level:0 landed in the SAME flattened index column as user
- // metadata named `level`, poisoning it multi-valued ([0, real]).
const entityForIndexing = {
id,
vector,
connections: new Map(),
+ level: 0,
type: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
...(params.visibility !== undefined &&
@@ -3908,22 +2057,11 @@ export class Brainy implements BrainyInterface {
}
: undefined
- // MT5: the pending marker RIDES the insert's own commit fact (same
- // generation, one atomic append) — threaded to persistSingleOp below.
- // A failed commit appends nothing, so no orphaned durable marker can
- // exist; the in-memory entry is harmless and reaped by the worker.
- const embedMarkers: FactMarkerRecord[] | undefined = deferringEmbed
- ? [this.enqueuePendingEmbed(id)]
- : undefined
-
const runInsert: TransactionFunction = async (tx) => {
// Operation 1: Save metadata FIRST (TypeAwareStorage caching)
// isNew=true: skip pre-read for rollback (entity doesn't exist yet)
- // hasVector: the vectored-noun ledger counts this insert iff its
- // vector is real/non-empty (never true for a deferred embed, whose
- // stub `vector` is `[]` — it counts later, at landing).
tx.addOperation(
- new SaveNounMetadataOperation(this.storage, id, storageMetadata, true, vector.length > 0)
+ new SaveNounMetadataOperation(this.storage, id, storageMetadata, true)
)
// Operation 2: Save vector data
@@ -3937,23 +2075,14 @@ export class Brainy implements BrainyInterface {
}, true)
)
- // Operation 3: Add to HNSW index (after entity saved). Gated on
- // `vector.length > 0`, not `!deferringEmbed`: a deferred embed has
- // nothing to index yet (the worker's atomic update inserts the real
- // vector later), and an explicit `vector: []` insert (the VFS root's
- // zero-norm fix — permanently unvectored plumbing, never embedded)
- // is exactly the same "nothing to index yet" shape. The zero-norm
- // BELT (a real all-zero vector, non-empty) is enforced inside
- // AddToVectorIndexOperation itself — see its JSDoc.
- if (vector.length > 0) {
- tx.addOperation(
- new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration)
- )
- }
+ // Operation 3: Add to HNSW index (after entity saved)
+ tx.addOperation(
+ new AddToHNSWOperation(this.index, id, vector)
+ )
// Operation 4: Add to metadata index
tx.addOperation(
- new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing, this.indexWriteGeneration)
+ new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing)
)
}
@@ -3983,7 +2112,7 @@ export class Brainy implements BrainyInterface {
const MAX_UPSERT_ATTEMPTS = 10
for (let attempt = 0; ; attempt++) {
try {
- await this.persistSingleOp({ nouns: [id] }, runInsert, insertPrecommit, addEvents, embedMarkers)
+ await this.persistSingleOp({ nouns: [id] }, runInsert, insertPrecommit, addEvents)
break
} catch (err) {
if (!(err instanceof InsertPreconditionExistsSignal)) {
@@ -4017,7 +2146,6 @@ export class Brainy implements BrainyInterface {
this._aggregationIndex.onEntityAdded(id, entityForIndexing)
}
- if (deferringEmbed) this.kickEmbedWorker()
return id
}
@@ -4193,16 +2321,6 @@ export class Brainy implements BrainyInterface {
}
// Route to metadata-only or full entity based on options
- // A PROJECTED get goes through the same seam every list page uses, so a
- // detail read of two scalars costs an index read rather than a record read.
- // It is checked before `includeVectors` because the two are incompatible by
- // construction: a projection returns the named fields, and a vector is not
- // one of them unless it was named.
- if (options?.fields !== undefined && options.fields.length > 0) {
- const page = await this.#hydratePage([id], options.fields)
- return page.get(id) ?? null
- }
-
const includeVectors = options?.includeVectors ?? false // Default: metadata-only (fast)
if (includeVectors) {
@@ -4249,170 +2367,6 @@ export class Brainy implements BrainyInterface {
* const children = childIds.map(id => childrenMap.get(id)).filter(Boolean)
* ```
*/
- /**
- * **The projection seam** — hydrate a page of ids under an optional `fields`
- * projection, opening the canonical record only when the index cannot serve
- * what was asked for.
- *
- * Without a projection this is exactly `batchGet`, byte for byte: the whole
- * point is that `fields` absent changes nothing.
- *
- * With one, the order is: ask the index for the named scalars in a single
- * batched door; see which requested fields it actually served; and read
- * records ONLY if something is still missing — and only to fill those fields.
- * A page whose every requested field is index-served performs zero canonical
- * reads, which is the whole reason the door exists.
- *
- * `guardFields` are fetched ALONGSIDE the projection and trimmed off before
- * the caller sees them. find()'s index-integrity guard re-validates every row
- * against its own predicate, and it reads the entity to do so — so a row
- * projected down to `title` would fail a `where: { kind }` it genuinely
- * matches, and the whole page would vanish. The fields a filter names are
- * fields the index can serve by definition, so carrying them costs nothing
- * and keeps the guard honest.
- *
- * A field nothing can supply is simply absent from the row. That is the
- * permissive law: a projection asks "these, if you have them", and an
- * optional field must not turn a list into an exception. It deliberately does
- * NOT route through the strict address resolver, which throws
- * `UnresolvableFieldError` for an unknown key — that strictness is right for
- * `orderBy`, where a typo silently changes the order, and wrong here, where
- * the honest answer is "this row does not have that".
- *
- * @param ids - Canonical ids for the page.
- * @param fields - The projection, or undefined for the full record.
- * @returns `id → entity`, projected when `fields` was given.
- */
- /**
- * The index keys find()'s integrity guard reads when it re-validates a row.
- *
- * The guard calls `entityMatchesFind(entity, params)`, so a projected entity
- * must still carry whatever the params constrain — otherwise a row that
- * genuinely matches is dropped for lacking the evidence. These are fetched
- * with the projection and trimmed off before the caller sees them.
- *
- * @param params - The find params.
- * @returns Index keys to carry through hydration.
- */
- #guardFieldsFor(params: FindParams): string[] {
- const keys: string[] = []
- if (params.where && typeof params.where === 'object') {
- // Top-level where keys only: nested `anyOf`/`allOf` branches are carried
- // by their own keys when the guard walks them, and a filter whose
- // evidence is missing keeps the row (the guard's own catch) rather than
- // dropping it.
- for (const key of Object.keys(params.where as Record)) {
- if (key === 'anyOf' || key === 'allOf' || key === 'not') continue
- keys.push(key)
- }
- }
- if (params.type !== undefined) keys.push('system.type')
- if (params.subtype !== undefined) keys.push('system.subtype')
- if (params.service !== undefined) keys.push('system.service')
- if (params.excludeVFS === true) keys.push('vfsType', 'isVFSEntity')
- return keys
- }
-
- async #hydratePage(
- ids: string[],
- fields?: readonly string[],
- guardFields: readonly string[] = []
- ): Promise