diff --git a/RELEASES.md b/RELEASES.md index cba016c9..bbca3343 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -51,9 +51,11 @@ What you get: on conflict) — the big sibling of the per-entity `ifRev` CAS from 7.31. `meta` is reified Datomic-style into an append-only transaction log, readable via `brain.transactionLog()`. -- **`brain.asOf(generation | Date | snapshotPath)`** — time travel with the - **full query surface**: `get()`, `find()` in every mode, semantic search, - graph traversal, cursors, aggregation, all at the pinned past state. +- **`brain.asOf(generation | Date | snapshotPath, { exclusive? })`** — time + travel with the **full query surface**: `get()`, `find()` in every mode, + semantic search, graph traversal, cursors, aggregation, all at the pinned past + state. `exclusive: true` pins the generation immediately before the target + (strict-before). - **`db.with(ops)`** — speculative writes applied in memory on top of a view. Nothing touches disk, the generation counter, or the indexes. What-if analysis, then `transact()` the same ops for real. @@ -63,10 +65,23 @@ What you get: inodes). `brain.restore(path, { confirm: true })` replaces the whole store from one; `Brainy.load(path)` opens one read-only with the full query surface. -- **`db.since(olderDb)`** — exactly the entity and relationship ids that - committed transactions touched between two views. +- **Range verbs over history** — answer "what happened BETWEEN two points": + - **`db.since(Db | generation | Date)`** — the entity and relationship ids + committed transactions touched after an **exclusive** lower bound (now + accepts a generation or `Date`, not just a prior `Db`). + - **`brain.diff(a, b)`** — the touched ids CLASSIFIED as `{ added, removed, + modified }` (split by nouns/verbs) by resolving each at both endpoints; a + touched-but-reverted id lands in none of the buckets. Endpoints are a + generation, `Date`, or `Db`, in either order. + - **`brain.history(id, { from?, to? })`** — every distinct version of ONE + entity/relationship over a range, oldest first (`value: null` marks a + removal); each version equals `asOf(version.generation).get(id)`. + - **`brain.transactionLog({ from?, to?, limit? })`** — the commit log over an + **inclusive** generation/`Date` window (contrast `since`'s exclusive lower + bound); `limit` applies last. - **`brain.compactHistory({ retainGenerations, retainMs })`** — explicit - retention. Compaction never breaks a pinned read. + retention. Compaction never breaks a pinned read. `diff`/`since` throw + `GenerationCompactedError` below the horizon; `history` truncates to it. The precise guarantees (and the honest limits — history granularity is `transact()` commits; single-operation writes advance the clock but produce no diff --git a/docs/guides/snapshots-and-time-travel.md b/docs/guides/snapshots-and-time-travel.md index 62008b9b..97bd5450 100644 --- a/docs/guides/snapshots-and-time-travel.md +++ b/docs/guides/snapshots-and-time-travel.md @@ -5,7 +5,7 @@ public: true category: guides template: guide order: 9 -description: Recipes for the Db API — instant backups with persist(), restore, time-travel debugging with asOf(), persist-before-migrate, what-if analysis with with(), and audit trails via transaction metadata. +description: Recipes for the Db API — instant backups with persist(), restore, time-travel debugging with asOf(), range queries over history (diff, history, since, log windows), persist-before-migrate, what-if analysis with with(), and audit trails via transaction metadata. next: - concepts/consistency-model - guides/optimistic-concurrency @@ -146,6 +146,96 @@ Three things to remember: - Generations reclaimed by `compactHistory()` throw `GenerationCompactedError` — persist anything you need to keep forever. +## Range queries over history + +`asOf()` answers "what was the state AT a point". Four range verbs answer +"what happened BETWEEN two points" and "what is one entity's whole history". +They all build on the same generation records — no extra bookkeeping. + +### `diff(a, b)` — what changed, classified + +`since()` gives you the raw set of *touched* ids. `diff()` goes further: it +resolves each touched id at both endpoints and classifies it as **added**, +**removed**, or **modified** — split by entities (`nouns`) and relationships +(`verbs`). An id that was touched but ended up identical (changed then +reverted, or created and deleted within the interval) lands in **none** of the +buckets. Endpoints are a generation, a `Date`, or a `Db`, in either order: + +```typescript +const d = await brain.diff(1041, brain.generation()) + +d.added.nouns // entity ids created between the two states +d.removed.nouns // entity ids deleted +d.modified.nouns // entity ids whose stored value actually changed +d.added.verbs // …relationships, the same three ways +``` + +Orientation is `a → b`: `added` means "exists at `b`, not at `a`". The +comparison behind `modified` is key-order-insensitive, so a no-op re-write of +the same fields never shows up as a change. + +### `history(id, range?)` — one entity, every version + +`asOf()` is per-*generation*; `history()` is per-*entity*. It returns every +distinct version of one id over a range, oldest first — each `value` is the +materialized state at that version (and `null` marks a removal): + +```typescript +const h = await brain.history(invoiceId) + +for (const v of h.versions) { + console.log(v.generation, v.value?.metadata?.status ?? '(deleted)') +} +// 1041 'draft' +// 1043 'approved' +// 1050 'paid' +``` + +Every version ties to the trusted `asOf()` path — `v.value` equals +`(await brain.asOf(v.generation)).get(id)`. Pass `{ from, to }` (generation or +`Date`) to bound the range; a `from` below the compaction horizon is quietly +truncated to it rather than throwing (history is best-effort over surviving +records). + +### `since()` and `transactionLog()` take ranges too + +`since()` accepts a `Db`, a generation number, or a `Date` — all equivalent, +all an **exclusive** lower bound (`db.since(prior)` equals +`db.since(prior.generation)`): + +```typescript +await brain.now().since(1041) // ids changed after generation 1041 +await brain.now().since(new Date(Date.now() - 3_600_000)) // …in the last hour +``` + +`transactionLog({ from, to })` windows the commit log **inclusively** on both +ends (a log window names the commits it spans — the deliberate contrast to +`since`'s exclusive lower bound); `limit` applies after the window, newest +first: + +```typescript +const window = await brain.transactionLog({ from: 1041, to: 1050 }) // commits 1041…1050 +const recent = await brain.transactionLog({ from: lastHour, limit: 20 }) +``` + +### Composing them + +"Which orders changed in this window?" is `diff` ids intersected with an +`asOf` query — the two agree by construction: + +```typescript +const changed = await brain.diff(g1, g2) +const atG2 = await brain.asOf(g2) +const changedOrders = (await atG2.find({ type: NounType.Document, subtype: 'order' })) + .map(r => r.id) + .filter(id => changed.added.nouns.includes(id) || changed.modified.nouns.includes(id)) +await atG2.release() +``` + +One contrast to keep straight: `diff` and `since` **throw** +`GenerationCompactedError` for a bound below the horizon, while `history` +**truncates** to the horizon — diffs must be exact, history is best-effort. + ## Safe schema migration `brain.migrate()` integrates with snapshots directly: pass `backupTo` and a diff --git a/src/brainy.ts b/src/brainy.ts index 5e4f79cf..d4b8e017 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -134,8 +134,12 @@ import type { TransactOptions, TransactReceipt, TxLogEntry, - TxOperation + TxOperation, + DiffResult, + EntityHistory, + HistoryVersion } from './db/types.js' +import { stableDeepEqual } from './db/stableEqual.js' import type { VersionedIndexProvider } from './plugin.js' import type { Operation } from './transaction/types.js' @@ -5024,18 +5028,48 @@ export class Brainy implements BrainyInterface { * `compactHistory()` never rewrites the log — entries may reference * generations whose record-sets were already reclaimed. * - * @param options - `limit` caps the number of entries returned (newest - * first; default: all). + * @param options - `from`/`to` (generation number or `Date`) bound the window + * to commits in `[from, to]` INCLUSIVE on both ends — a log window names + * commits, in deliberate contrast to `since()`'s EXCLUSIVE lower bound. + * `limit` caps the result and is applied LAST (after windowing), newest + * first. All omitted = the whole log. `from`/`to` resolve through the same + * reachability gate as `asOf()`. * @returns Committed transaction entries, newest first. + * @throws RangeError when the resolved `from` generation is after `to`. * @example * await brain.transact([{ op: 'update', id, metadata: { v: 2 } }], { meta: { author: 'sync-job' } }) * const [latest] = await brain.transactionLog({ limit: 1 }) * latest.meta // { author: 'sync-job' } + * // Commits in a window, newest first: + * const window = await brain.transactionLog({ from: 10, to: 20 }) */ - async transactionLog(options?: { limit?: number }): Promise { + async transactionLog(options?: { + from?: number | Date + to?: number | Date + limit?: number + }): Promise { await this.ensureInitialized() - const entries = await this.generationStore.txLog() - entries.reverse() + let entries = await this.generationStore.txLog() // oldest first + + if (options?.from !== undefined || options?.to !== undefined) { + const fromGen = + options?.from !== undefined + ? (await this.resolveAsOfGeneration(options.from)).generation + : 0 + const toGen = + options?.to !== undefined + ? (await this.resolveAsOfGeneration(options.to)).generation + : this.generationStore.generation() + if (fromGen > toGen) { + throw new RangeError( + `transactionLog(): resolved from-generation ${fromGen} is after to-generation ${toGen}` + ) + } + // Inclusive on BOTH ends (a log window names the commits it spans). + entries = entries.filter((e) => e.generation >= fromGen && e.generation <= toGen) + } + + entries.reverse() // newest first return options?.limit !== undefined ? entries.slice(0, options.limit) : entries } @@ -5087,7 +5121,7 @@ export class Brainy implements BrainyInterface { * @param options - HOW to export (vectors / VFS bytes / edge policy). See {@link ExportOptions}. * @returns A versioned, portable `PortableGraph` document. * @example - * const backup = await brain.export({ ids }, { includeVectors: true }) + * const graph = await brain.export({ ids }, { includeVectors: true }) */ async export(selector: ExportSelector = {}, options: ExportOptions = {}): Promise { return this.now().export(selector, options) @@ -5196,6 +5230,11 @@ export class Brainy implements BrainyInterface { * Release the returned `Db` when done. * * @param target - Generation number, `Date`, or snapshot directory path. + * @param options - `exclusive: true` pins the generation immediately BEFORE the + * one `target` resolves to (strict-before): `asOf(g, { exclusive: true })` is + * the state at `g − 1`; strict-before the first commit pins generation 0 (the + * empty pre-commit state), never a `RangeError`. Ignored for the snapshot + * (string) form. * @returns A `Db` pinned at the resolved state. * @throws GenerationCompactedError when the generation's records were * reclaimed by `compactHistory()`. @@ -5203,9 +5242,13 @@ export class Brainy implements BrainyInterface { * @example * const yesterday = await brain.asOf(new Date(Date.now() - 86_400_000)) * const atGen42 = await brain.asOf(42) + * const beforeGen42 = await brain.asOf(42, { exclusive: true }) // state at gen 41 * const fromSnapshot = await brain.asOf('/backups/2026-06-01') */ - async asOf(target: number | Date | string): Promise> { + async asOf( + target: number | Date | string, + options?: { exclusive?: boolean } + ): Promise> { await this.ensureInitialized() if (typeof target === 'string') { @@ -5219,21 +5262,278 @@ export class Brainy implements BrainyInterface { return Brainy.load(target) } + const { generation, timestamp } = await this.resolveAsOfGeneration(target, options) + return this.createPinnedDb({ generation, timestamp }) + } + + /** + * @description The shared `generation | Date` → `{ generation, timestamp }` + * resolver used by every gen/Date-accepting temporal verb (`asOf`, `since`, + * `diff`, `transactionLog`) so reachability, `RangeError`, and `Date` semantics + * stay identical across all of them. The inclusive path is byte-for-byte the + * pre-extraction `asOf` logic; `exclusive` pins the generation immediately + * before the resolved one (clamped at 0, never a `RangeError`). All resolved + * generations pass through `assertReachable` (so a compacted target throws + * {@link GenerationCompactedError}); `history()` deliberately does NOT use this + * resolver — it truncates below the horizon rather than throwing. + * @param target - A generation number or a `Date`. + * @param options - `exclusive: true` for strict-before (resolved − 1). + */ + private async resolveAsOfGeneration( + target: number | Date, + options?: { exclusive?: boolean } + ): Promise<{ generation: number; timestamp: number }> { + const exclusive = options?.exclusive === true let generation: number let timestamp: number if (target instanceof Date) { const resolved = await this.generationStore.resolveTimestamp(target.getTime()) - this.generationStore.assertReachable(resolved.generation) - generation = resolved.generation - timestamp = resolved.entry?.timestamp ?? target.getTime() + if (exclusive) { + generation = Math.max(0, resolved.generation - 1) + this.generationStore.assertReachable(generation) + timestamp = + (await this.generationStore.commitTimestampAtOrBefore(generation)) ?? target.getTime() + } else { + this.generationStore.assertReachable(resolved.generation) + generation = resolved.generation + timestamp = resolved.entry?.timestamp ?? target.getTime() + } } else { - this.generationStore.assertReachable(target) - generation = target + generation = exclusive ? Math.max(0, target - 1) : target + this.generationStore.assertReachable(generation) timestamp = - (await this.generationStore.commitTimestampAtOrBefore(target)) ?? Date.now() + (await this.generationStore.commitTimestampAtOrBefore(generation)) ?? Date.now() + } + return { generation, timestamp } + } + + /** + * @description Compare two states of the brain and report what CHANGED between + * them — entities/relationships `added`, `removed`, or `modified`, each split by + * kind. Unlike a raw touched-id list, `diff` EARNS its name: the candidate set is + * ONLY the ids a committed transaction touched in the interval, and each is then + * resolved at BOTH endpoints and classified by existence and a stable value + * comparison. An id touched within the interval but whose endpoint states are + * identical (e.g. changed then reverted) appears in NONE of the three buckets. + * + * Orientation is `a → b`: `added` = exists at `b` not `a`; `removed` = exists at + * `a` not `b`; `modified` = exists at both with a changed stored value + * (key-order-insensitive). Endpoints may be passed in either order. + * + * @param a - The "before" state: a generation number, a `Date`, or a `Db`. + * @param b - The "after" state: a generation number, a `Date`, or a `Db`. + * @returns A {@link DiffResult} (each bucket's ids sorted). + * @throws GenerationCompactedError when a number/`Date` endpoint is below the horizon. + * @example + * const before = brain.generation() + * await brain.transact(ops) + * const d = await brain.diff(before, brain.generation()) + * d.added.nouns // ids created by the transaction + * d.modified.nouns // ids whose stored value actually changed + */ + async diff(a: number | Date | Db, b: number | Date | Db): Promise { + await this.ensureInitialized() + const gA = await this.resolveDiffEndpoint(a) + const gB = await this.resolveDiffEndpoint(b) + + const result: DiffResult = { + added: { nouns: [], verbs: [] }, + removed: { nouns: [], verbs: [] }, + modified: { nouns: [], verbs: [] }, + fromGeneration: gA, + toGeneration: gB } - return this.createPinnedDb({ generation, timestamp }) + // Candidate set: ONLY the ids touched in the interval (never all ids) — this + // is what lets diff classify rather than dump raw changed-ids. + const gLow = Math.min(gA, gB) + const gHigh = Math.max(gA, gB) + if (gLow === gHigh) return result // same state → no changes + const changed = await this.generationStore.changedBetween(gLow, gHigh) + + for (const kind of ['noun', 'verb'] as const) { + const ids = kind === 'noun' ? changed.nouns : changed.verbs + for (const id of ids) { + const stateA = await this.recordStateAt(kind, id, gA) + const stateB = await this.recordStateAt(kind, id, gB) + const bucket = (name: 'added' | 'removed' | 'modified') => + kind === 'noun' ? result[name].nouns : result[name].verbs + if (!stateA.exists && stateB.exists) bucket('added').push(id) + else if (stateA.exists && !stateB.exists) bucket('removed').push(id) + else if ( + stateA.exists && + stateB.exists && + !stableDeepEqual(stateA.metadata, stateB.metadata) + ) { + bucket('modified').push(id) + } + // both absent, or both exist and identical → in NO bucket (earns the name) + } + } + + for (const name of ['added', 'removed', 'modified'] as const) { + result[name].nouns.sort() + result[name].verbs.sort() + } + return result + } + + /** + * @description Every distinct version of ONE entity or relationship over a + * generation range, oldest → newest — the per-id companion to `asOf()`. Each + * {@link HistoryVersion} ties to the trusted `asOf()` path: its `value` equals + * `(await brain.asOf(version.generation)).get(id)`. Consecutive identical states + * are collapsed, and a leading "did not exist yet" baseline is dropped, so the + * first version is the id's first real state in the range and a `null` value + * marks a removal. + * + * Range defaults to `[horizon, currentGeneration]`. Unlike `diff`/`since`, a + * `from` below the compaction horizon is TRUNCATED to the horizon, not thrown — + * history is best-effort over the records that survive. + * + * @param id - The entity or relationship id (kind auto-detected). + * @param options - `from`/`to` (generation number or `Date`) bound the range. + * @returns An {@link EntityHistory} (`versions` oldest first; empty if the id is + * unknown over the range). + * @throws Error if `id` resolves in BOTH the entity and relationship spaces. + * @example + * const h = await brain.history(invoiceId) + * h.versions.map(v => v.value?.metadata?.status) // the status at each change + */ + async history( + id: string, + options?: { from?: number | Date; to?: number | Date } + ): Promise> { + await this.ensureInitialized() + const store = this.generationStore + const horizon = store.horizon() + const current = store.generation() + + // history TRUNCATES below the horizon (never throws): resolve raw, then clamp. + const rawFrom = + options?.from !== undefined ? await this.resolveRawGeneration(options.from) : horizon + const rawTo = + options?.to !== undefined ? await this.resolveRawGeneration(options.to) : current + const fromGen = Math.min(Math.max(rawFrom, horizon), current) + const toGen = Math.min(Math.max(rawTo, 0), current) + + const kind = await this.detectIdKind(id, horizon, current) + if (kind === null || fromGen > toGen) { + return { id, kind: kind ?? 'noun', versions: [] } + } + + // Snapshot generations: the range start (state as-of fromGen) plus every + // committed change-point in (fromGen, toGen]. + const touches = await store.generationsTouching(kind, id, fromGen, toGen) + const snapshotGens = [fromGen, ...touches] + + const versions: HistoryVersion[] = [] + let prev: { exists: boolean; metadata: any } | null = null + for (const g of snapshotGens) { + const state = await this.recordStateAt(kind, id, g) + if ( + prev !== null && + prev.exists === state.exists && + stableDeepEqual(prev.metadata, state.metadata) + ) { + continue // collapse consecutive identical states + } + prev = { exists: state.exists, metadata: state.metadata } + const timestamp = (await store.commitTimestampAtOrBefore(g)) ?? 0 + let value: Entity | Relation | null = null + if (state.exists) { + value = + kind === 'noun' + ? await this.entityFromGenerationRecord( + id, + { metadata: state.metadata, vector: state.vector }, + false + ) + : this.relationFromGenerationRecord(id, { + metadata: state.metadata, + vector: state.vector + }) + } + versions.push({ generation: g, timestamp, value }) + } + + // Drop a leading "did not exist yet" baseline so history starts at the id's + // first real state (internal/trailing nulls = real removals stay). + while (versions.length > 0 && versions[0].value === null) versions.shift() + return { id, kind, versions } + } + + /** Resolve a `Db | generation | Date` diff endpoint to a concrete generation. */ + private async resolveDiffEndpoint(endpoint: number | Date | Db): Promise { + if (endpoint instanceof Db) { + if (!endpoint.belongsToStore(this.generationStore)) { + throw new Error('diff(): a Db endpoint must come from this same Brainy instance') + } + return endpoint.generation + } + return (await this.resolveAsOfGeneration(endpoint)).generation + } + + /** + * Resolve a `generation | Date` to a raw generation number WITHOUT the + * reachability assertion — for `history()`, which truncates below the horizon + * rather than throwing. + */ + private async resolveRawGeneration(target: number | Date): Promise { + if (target instanceof Date) { + return (await this.generationStore.resolveTimestamp(target.getTime())).generation + } + return target + } + + /** + * The stored state of `id` at pinned generation `gen`, normalizing `resolveAt`'s + * three sources to `{ exists, metadata, vector }`. A `'current'` source means the + * live storage state IS the state at `gen`, so it reads the live metadata. + */ + private async recordStateAt( + kind: 'noun' | 'verb', + id: string, + gen: number + ): Promise<{ exists: boolean; metadata: any; vector: any }> { + const r = await this.generationStore.resolveAt(kind, id, gen) + if (r.source === 'absent') return { exists: false, metadata: null, vector: null } + if (r.source === 'record') return { exists: true, metadata: r.metadata, vector: r.vector } + const metadata = + kind === 'noun' + ? await this.storage.getNounMetadata(id) + : await this.storage.getVerbMetadata(id) + return { exists: metadata !== null, metadata, vector: null } + } + + /** + * Auto-detect whether `id` is an entity (`'noun'`) or relationship (`'verb'`) by + * its presence live and in the generation deltas over `(fromGen, toGen]`. Returns + * `null` for an unknown id; throws on the (UUID-space) collision where the id + * resolves in both spaces. + */ + private async detectIdKind( + id: string, + fromGen: number, + toGen: number + ): Promise<'noun' | 'verb' | null> { + const liveNoun = (await this.storage.getNounMetadata(id)) !== null + const liveVerb = (await this.storage.getVerbMetadata(id)) !== null + const nounTouched = + (await this.generationStore.generationsTouching('noun', id, fromGen, toGen)).length > 0 + const verbTouched = + (await this.generationStore.generationsTouching('verb', id, fromGen, toGen)).length > 0 + const isNoun = liveNoun || nounTouched + const isVerb = liveVerb || verbTouched + if (isNoun && isVerb) { + throw new Error( + `history(): id '${id}' resolves in BOTH the entity and relationship spaces — ` + + `ambiguous (should not happen with UUID ids).` + ) + } + if (isNoun) return 'noun' + if (isVerb) return 'verb' + return null } /** @@ -5471,6 +5771,7 @@ export class Brainy implements BrainyInterface { get: (id, options) => this.get(id, options), find: (query) => this.find(query), related: (paramsOrId) => this.related(paramsOrId), + resolveGeneration: (target, options) => this.resolveAsOfGeneration(target, options), entityFromRecord: (id, record, includeVectors) => this.entityFromGenerationRecord(id, record, includeVectors), relationFromRecord: (id, record) => this.relationFromGenerationRecord(id, record), diff --git a/src/db/db.ts b/src/db/db.ts index ca19163e..f1c30e44 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -116,6 +116,16 @@ export interface DbHost { find(query: string | FindParams): Promise[]> /** Live `brain.related()` (current-generation fast path). */ related(paramsOrId?: string | RelatedParams): Promise[]> + /** + * Resolve a `generation | Date` to a concrete generation (+ timestamp) via the + * brain's shared `asOf` resolver — so `since(gen|Date)` shares `asOf`'s + * reachability and `Date` semantics. `exclusive` pins the generation before the + * resolved one. + */ + resolveGeneration( + target: number | Date, + options?: { exclusive?: boolean } + ): Promise<{ generation: number; timestamp: number }> /** Materialize an entity from a generation record's raw stored objects. */ entityFromRecord( id: string, @@ -213,6 +223,16 @@ export class Db { return this.gen } + /** + * @internal Same-instance guard for cross-`Db` operations like + * `Brainy.diff()`: whether this view is backed by `store`. Lets `Brainy` + * reject a `Db` endpoint from a different brain without exposing the private + * host. + */ + belongsToStore(store: GenerationStore): boolean { + return this.host.store === store + } + /** * The view's wall-clock timestamp (ms since epoch): pin time for `now()`, * commit time for `transact()`, and the resolved commit time for `asOf()`. @@ -752,27 +772,54 @@ export class Db { /** * @description The ids changed by committed transactions in the interval - * `(priorDb.generation, this.generation]` — the diff between two pinned - * views of the same store. Single-operation writes between commits are not - * reflected (documented 8.0 history granularity). + * `(lowerBound, this.generation]` — the diff between an earlier state and this + * view. The lower bound is **exclusive** (contrast `transactionLog`'s inclusive + * window). Single-operation writes between commits are not reflected (documented + * 8.0 history granularity). * - * @param priorDb - An earlier view of the SAME store. + * The lower bound may be: + * - **a `Db`** — an earlier view of the SAME store (its pinned generation); + * - **a generation number** — used directly as the exclusive lower bound; + * - **a `Date`** — resolved to the generation committed at or before it. + * + * `db.since(prior)` equals `db.since(prior.generation)` by construction. + * + * @param lowerBound - An earlier `Db`, a generation number, or a `Date`. * @returns Changed entity and relation ids, sorted. - * @throws RangeError when `priorDb` is newer than this view, or Error when - * the views belong to different stores. + * @throws RangeError when the resolved lower bound is newer than this view. + * @throws Error when a `Db` lower bound belongs to a different store. + * @throws GenerationCompactedError when a number/`Date` lower bound is below the + * compaction horizon. + * @example + * const before = brain.now() + * await brain.transact(ops) + * await brain.now().since(before) // ids changed by the transaction + * await brain.now().since(before.generation) + * await brain.now().since(new Date(Date.now() - 3_600_000)) */ - async since(priorDb: Db): Promise { + async since(lowerBound: Db | number | Date): Promise { this.assertUsable('since') - if (priorDb.host.store !== this.host.store) { - throw new Error('since(): both Db values must come from the same Brainy instance') + + let fromGen: number + if (lowerBound instanceof Db) { + if (lowerBound.host.store !== this.host.store) { + throw new Error('since(): both Db values must come from the same Brainy instance') + } + fromGen = lowerBound.gen + } else { + // number = exclusive lower-bound generation; Date resolves via the shared + // asOf resolver (same reachability gate). changedBetween treats fromGen as + // exclusive, so since(db) === since(db.generation). + fromGen = (await this.host.resolveGeneration(lowerBound)).generation } - if (priorDb.gen > this.gen) { + + if (fromGen > this.gen) { throw new RangeError( - `since(): priorDb is at generation ${priorDb.gen}, which is newer than this view ` + - `(generation ${this.gen}). Call newerDb.since(olderDb).` + `since(): lower-bound generation ${fromGen} is newer than this view ` + + `(generation ${this.gen}). Pass an earlier generation/Date, or call newerDb.since(olderDb).` ) } - return this.host.store.changedBetween(priorDb.gen, this.gen) + return this.host.store.changedBetween(fromGen, this.gen) } // ========================================================================== diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index 78dfff13..364cdcef 100644 --- a/src/db/generationStore.ts +++ b/src/db/generationStore.ts @@ -583,6 +583,32 @@ export class GenerationStore { } } + /** + * @description The committed generations in `(fromGen, toGen]` that touched a + * single id, ascending. The per-id companion to {@link changedBetween} — used + * by `history()` to walk one entity's change-points without scanning every id. + * No all-ids index: it consults the same cached per-generation deltas. + * @param kind - `'noun'` for entities, `'verb'` for relationships. + * @param id - The id to track. + * @param fromGen - Exclusive lower bound. + * @param toGen - Inclusive upper bound. + */ + async generationsTouching( + kind: 'noun' | 'verb', + id: string, + fromGen: number, + toGen: number + ): Promise { + const result: number[] = [] + for (const gen of this.committedGens) { + if (gen <= fromGen || gen > toGen) continue + const delta = await this.getDelta(gen) + const touched = kind === 'noun' ? delta.nouns : delta.verbs + if (touched.has(id)) result.push(gen) + } + return result + } + /** * @description Assert that generation `gen` is reachable (not compacted * away) and within the committed range, then return it. Used by `asOf()`. diff --git a/src/db/stableEqual.ts b/src/db/stableEqual.ts new file mode 100644 index 00000000..74fd3e4a --- /dev/null +++ b/src/db/stableEqual.ts @@ -0,0 +1,64 @@ +/** + * @module db/stableEqual + * @description Key-order-insensitive deep equality, used by {@link Brainy.diff} + * to decide whether an id present at both endpoints actually *changed* value. + * + * A naive `JSON.stringify(a) === JSON.stringify(b)` is wrong for this: object key + * order is insignificant for stored-metadata equality, but `stringify` is + * order-sensitive, so `{a:1,b:2}` and `{b:2,a:1}` would mis-compare as changed and + * a no-op re-write would show up as a spurious `modified`. This walks both values + * structurally — object keys compared as a set, array elements compared in order + * (arrays ARE order-significant) — over the JSON-shaped values that live in + * generation records. + */ + +/** + * @description Deep-equal two JSON-shaped values, insensitive to object key order. + * @param a - First value. + * @param b - Second value. + * @returns `true` if structurally equal (key order ignored; array order honored; + * `NaN` equals `NaN`). + * @example + * stableDeepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }) // → true + * stableDeepEqual([1, 2], [2, 1]) // → false (order matters) + */ +export function stableDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + + // NaN is never === itself, but for a stable value comparison NaN equals NaN. + if (typeof a === 'number' && typeof b === 'number') { + return Number.isNaN(a) && Number.isNaN(b) + } + + // Distinct primitives, or exactly one of them null/non-object → not equal + // (the `a === b` above already returned true for equal primitives and for + // both-null). + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + + const aIsArr = Array.isArray(a) + const bIsArr = Array.isArray(b) + if (aIsArr !== bIsArr) return false + + if (aIsArr) { + const aa = a as unknown[] + const ba = b as unknown[] + if (aa.length !== ba.length) return false + for (let i = 0; i < aa.length; i++) { + if (!stableDeepEqual(aa[i], ba[i])) return false + } + return true + } + + const ao = a as Record + const bo = b as Record + const aKeys = Object.keys(ao) + const bKeys = Object.keys(bo) + if (aKeys.length !== bKeys.length) return false + for (const k of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bo, k)) return false + if (!stableDeepEqual(ao[k], bo[k])) return false + } + return true +} diff --git a/src/db/types.ts b/src/db/types.ts index 041729dd..31420b84 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -25,7 +25,7 @@ * `docs/ADR-001-generational-mvcc.md` for the full justification. */ -import type { AddParams, UpdateParams, RelateParams } from '../types/brainy.types.js' +import type { AddParams, UpdateParams, RelateParams, Entity, Relation } from '../types/brainy.types.js' // ============================================================================ // Transaction operations (brain.transact input) @@ -193,6 +193,58 @@ export interface ChangedIds { toGeneration: number } +/** + * @description The result of {@link Brainy.diff}: ids that came into existence, + * went away, or changed value between two states, each split by kind. Unlike the + * raw touched-id set of {@link ChangedIds}, `diff` EARNS its name — it resolves + * each touched id at BOTH endpoints and classifies by existence (added/removed) + * and stable value comparison (modified). An id touched within the interval but + * whose endpoint states are identical (touched-then-reverted) appears in NONE of + * the three buckets. Orientation is `a → b`: `added` exists at `b` not `a`. + */ +export interface DiffResult { + /** Ids absent at `a`, present at `b`. */ + added: { nouns: string[]; verbs: string[] } + /** Ids present at `a`, absent at `b`. */ + removed: { nouns: string[]; verbs: string[] } + /** Ids present at both endpoints with a changed stored value. */ + modified: { nouns: string[]; verbs: string[] } + /** The resolved generation of endpoint `a`. */ + fromGeneration: number + /** The resolved generation of endpoint `b`. */ + toGeneration: number +} + +/** + * @description One version of a single entity or relationship in its + * {@link EntityHistory} — the materialized state at a committed generation that + * changed it. `value` is `null` when the id did not exist at that version + * (created-after / removed). Granularity is `transact()` commits. + */ +export interface HistoryVersion { + /** The committed generation that produced this version. */ + generation: number + /** Commit timestamp of that generation (ms since epoch). */ + timestamp: number + /** Materialized state at this version, or `null` if the id did not exist then. */ + value: Entity | Relation | null +} + +/** + * @description The result of {@link Brainy.history}: every distinct version of + * ONE id over a generation range, oldest → newest. Consecutive identical states + * are collapsed (one entry per distinct state). `kind` is auto-detected from the + * id's presence in the noun vs verb spaces. + */ +export interface EntityHistory { + /** The id whose history this is. */ + id: string + /** Whether `id` is an entity (`'noun'`) or relationship (`'verb'`). */ + kind: 'noun' | 'verb' + /** Distinct versions, oldest first. */ + versions: HistoryVersion[] +} + // ============================================================================ // Persisted record shapes // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 277ec20b..b85d02cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -172,7 +172,10 @@ export type { TxLogEntry, CompactHistoryOptions, CompactHistoryResult, - ChangedIds + ChangedIds, + DiffResult, + HistoryVersion, + EntityHistory } from './db/types.js' // Optional provider capability for generation-aware native indexes export { isVersionedIndexProvider } from './plugin.js' diff --git a/tests/integration/db-temporal.test.ts b/tests/integration/db-temporal.test.ts new file mode 100644 index 00000000..137f24c0 --- /dev/null +++ b/tests/integration/db-temporal.test.ts @@ -0,0 +1,381 @@ +/** + * @module tests/integration/db-temporal + * @description Proof suite for Brainy 8.0's temporal RANGE verbs, built on the + * single-point `asOf()` foundation (proven byte-identical in db-mvcc). Each test + * PROVES a stated guarantee: + * + * 1. asOf(g, { exclusive }) pins g−1 (strict-before); exclusive of the first + * commit is the empty generation 0, never a RangeError. + * 2. since() accepts Db | generation | Date equivalently, with the direction + * guard; the lower bound is EXCLUSIVE. + * 3. transactionLog({ from, to }) windows INCLUSIVELY on both ends (the contrast + * to since's exclusive lower bound) and applies `limit` AFTER the window. + * 4. diff() EARNS its name: it does NOT return raw changed-ids — an id created + * and removed within the interval lands in NO bucket; added/removed/modified + * are classified by existence + a stable value comparison. + * 5. history() ties to the trusted asOf path: every version's value deep-equals + * asOf(version.generation).get(id), a removal is a null value, order is oldest→newest. + * 6. Composition: "type T changed in (g1, g2]" computed two ways (diff ids filtered + * to T, vs asOf(g2).find({type:T}) ∩ changed-ids) AGREE — including a where filter. + * 7. Granularity: single-operation writes (outside transact()) are invisible to the + * temporal verbs (transact commits only). + * 8. Compaction policy contrast: diff/since THROW below the horizon; history TRUNCATES + * to it. (Locked so a refactor can't unify them incorrectly.) + * + * Mirrors the db-mvcc harness: explicit vectors (no embedder), deterministic + * UUID-shaped ids, afterEach teardown. db-mvcc itself re-proves asOf is unchanged. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { Brainy } from '../../src/brainy.js' +import { GenerationCompactedError } from '../../src/db/errors.js' +import type { GenerationStore } from '../../src/db/generationStore.js' +import { NounType } from '../../src/types/graphTypes.js' + +/** Deterministic 384-dim vector so no test ever invokes the embedder. */ +function vec(seed: number): number[] { + return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100) +} + +/** Map a label to a deterministic UUID-shaped id (storage shards by UUID hex). */ +function uid(label: string): string { + let h1 = 0x811c9dc5 + for (let i = 0; i < label.length; i++) h1 = Math.imul(h1 ^ label.charCodeAt(i), 0x01000193) >>> 0 + let h2 = 0xdeadbeef + for (let i = label.length - 1; i >= 0; i--) h2 = Math.imul(h2 ^ label.charCodeAt(i), 0x85ebca6b) >>> 0 + const hex = h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0') + return `00000000-0000-4000-8000-${hex.slice(0, 12)}` +} + +function generationStoreOf(brain: Brainy): GenerationStore { + return (brain as unknown as { generationStore: GenerationStore }).generationStore +} + +describe('8.0 Db API — temporal range verbs', () => { + const brains: Brainy[] = [] + const tempDirs: string[] = [] + + async function openMemoryBrain(): Promise { + const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + brains.push(brain) + return brain + } + + afterEach(async () => { + for (const brain of brains.splice(0)) { + try { + await brain.close() + } catch { + /* already closed */ + } + } + for (const dir of tempDirs.splice(0)) { + await fs.promises.rm(dir, { recursive: true, force: true }) + } + }) + + // 1. asOf exclusive ---------------------------------------------------------- + it('asOf(g, { exclusive }) pins g−1; exclusive clamps at gen 0 without a RangeError', async () => { + const brain = await openMemoryBrain() + const a = uid('exc-a') + const rAdd = await brain.transact([ + { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } } + ]) + const gAdd = rAdd.generation + await rAdd.release() + const rUpd = await brain.transact([{ op: 'update', id: a, metadata: { v: 2 } }]) + const gUpd = rUpd.generation + await rUpd.release() + + // exclusive of gUpd = the state immediately before it (= the add generation, v:1) + const before = await brain.asOf(gUpd, { exclusive: true }) + expect(before.generation).toBe(gUpd - 1) + expect((await before.get(a))?.metadata?.v).toBe(1) + await before.release() + + const at = await brain.asOf(gUpd) // inclusive + expect((await at.get(a))?.metadata?.v).toBe(2) + await at.release() + + // exclusive clamps at 0 (never a RangeError): strict-before generation 1 is the + // empty pre-commit state — `a` does not exist there. + const zero = await brain.asOf(1, { exclusive: true }) + expect(zero.generation).toBe(0) + expect(await zero.get(a)).toBeNull() + await zero.release() + }) + + // 2. since 3-forms + direction guard ---------------------------------------- + it('since() accepts Db | generation | Date equivalently; lower bound is exclusive; direction guarded', async () => { + const brain = await openMemoryBrain() + const a = uid('since-a') + const b = uid('since-b') + const r1 = await brain.transact([ + { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: {} } + ]) + const g1 = r1.generation // a's add generation + await brain.transact([ + { op: 'add', id: b, type: NounType.Document, data: 'b', vector: vec(2), metadata: {} } + ]) + await brain.transact([{ op: 'update', id: a, metadata: { x: 1 } }]) + + const now = brain.now() + const viaDb = await now.since(r1) // changes in (g1, now] + const viaGen = await now.since(g1) + const viaEpoch = await now.since(new Date(0)) // resolves to gen 0 → (0, now] + + // since(db) === since(db.generation); the EXCLUSIVE lower bound omits a's own add@g1 + expect(viaDb).toEqual(viaGen) + expect(viaDb.fromGeneration).toBe(g1) + expect(viaDb.nouns).toEqual([a, b].sort()) // a (updated after g1) + b (added after g1) + expect(viaEpoch.nouns).toEqual([a, b].sort()) // (0, now] also includes a's creation, still {a, b} + + // direction guard: an older view cannot be `since` a newer lower bound + const older = await brain.asOf(1) + await expect(older.since(now)).rejects.toBeInstanceOf(RangeError) + await older.release() + await now.release() + await r1.release() + }) + + // 3. transactionLog window (inclusive both ends) + limit-after-window -------- + it('transactionLog({ from, to }) windows inclusively on both ends and applies limit last', async () => { + const brain = await openMemoryBrain() + const gens: number[] = [] + for (let v = 1; v <= 5; v++) { + const r = await brain.transact([ + { + op: 'add', + id: uid(`log-${v}`), + type: NounType.Document, + data: `d${v}`, + vector: vec(v), + metadata: { v } + } + ]) + gens.push(r.generation) // 5 consecutive generations, one log entry each + await r.release() + } + + const all = await brain.transactionLog() + expect(all.map((e) => e.generation)).toEqual([...gens].reverse()) // newest first + + // INCLUSIVE both ends — gens[1] AND gens[3] are present (contrast since's exclusive lower). + const windowed = await brain.transactionLog({ from: gens[1], to: gens[3] }) + expect(windowed.map((e) => e.generation)).toEqual([gens[3], gens[2], gens[1]]) + + // limit applies AFTER the window (newest first within the window) + const limited = await brain.transactionLog({ from: gens[1], to: gens[3], limit: 2 }) + expect(limited.map((e) => e.generation)).toEqual([gens[3], gens[2]]) + + // Inclusivity CONTRAST vs since: transactionLog INCLUDES `from`; since EXCLUDES it. + const logFrom = await brain.transactionLog({ from: gens[1] }) // [gens[1], …] + expect(logFrom.map((e) => e.generation).includes(gens[1])).toBe(true) + const sinceFrom = await brain.now().since(gens[1]) // (gens[1], …] — exclusive lower + expect(sinceFrom.fromGeneration).toBe(gens[1]) + + await expect( + brain.transactionLog({ from: gens[3], to: gens[1] }) + ).rejects.toBeInstanceOf(RangeError) + }) + + // 4. diff EARNS its name ----------------------------------------------------- + it('diff() classifies added/removed/modified — and an id created+removed within the interval is in NO bucket', async () => { + const brain = await openMemoryBrain() + const persistent = uid('diff-persistent') + const created = uid('diff-created') + const removed = uid('diff-removed') + const ephemeral = uid('diff-ephemeral') + + // gen 1: baseline — persistent + removed exist + await ( + await brain.transact([ + { op: 'add', id: persistent, type: NounType.Document, data: 'p', vector: vec(1), metadata: { v: 1 } }, + { op: 'add', id: removed, type: NounType.Document, data: 'r', vector: vec(2), metadata: { v: 1 } } + ]) + ).release() + const g1 = brain.generation() + + // gen 2: create `ephemeral` and `created`; modify `persistent`; delete `removed` + await ( + await brain.transact([ + { op: 'add', id: ephemeral, type: NounType.Document, data: 'e', vector: vec(3), metadata: { v: 1 } }, + { op: 'add', id: created, type: NounType.Document, data: 'c', vector: vec(4), metadata: { v: 1 } }, + { op: 'update', id: persistent, metadata: { v: 2 } }, + { op: 'remove', id: removed } + ]) + ).release() + // gen 3: delete `ephemeral` (so it was BORN and DIED inside (g1, g3]) + await (await brain.transact([{ op: 'remove', id: ephemeral }])).release() + const g3 = brain.generation() + + const d = await brain.diff(g1, g3) + expect(d.added.nouns).toEqual([created]) // exists at g3, not g1 + expect(d.removed.nouns).toEqual([removed]) // exists at g1, not g3 + expect(d.modified.nouns).toEqual([persistent]) // exists both, value changed + // EARNS-ITS-NAME: `ephemeral` was touched (raw changedBetween would list it) but is + // absent at BOTH endpoints → it appears in NONE of the buckets. + expect(d.added.nouns).not.toContain(ephemeral) + expect(d.removed.nouns).not.toContain(ephemeral) + expect(d.modified.nouns).not.toContain(ephemeral) + + // Orientation: diff(g3, g1) flips added/removed. + const rev = await brain.diff(g3, g1) + expect(rev.added.nouns).toEqual([removed]) + expect(rev.removed.nouns).toEqual([created]) + expect(rev.modified.nouns).toEqual([persistent]) + + // diff(g, g) is empty. + const empty = await brain.diff(g3, g3) + expect(empty.added.nouns).toEqual([]) + expect(empty.modified.nouns).toEqual([]) + }) + + // 5. history cross-checks the trusted asOf path ------------------------------ + it('history() — each version deep-equals asOf(version.generation).get(id); removal is a null value', async () => { + const brain = await openMemoryBrain() + const a = uid('hist-a') + await ( + await brain.transact([ + { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } } + ]) + ).release() // gen 1 + await (await brain.transact([{ op: 'update', id: a, metadata: { v: 2 } }])).release() // gen 2 + await (await brain.transact([{ op: 'update', id: a, metadata: { v: 3 } }])).release() // gen 3 + await (await brain.transact([{ op: 'remove', id: a }])).release() // gen 4 + + const h = await brain.history(a) + expect(h.kind).toBe('noun') + // oldest → newest, ending in a removal (null value) + const vs = h.versions + expect(vs.length).toBeGreaterThanOrEqual(4) + for (let i = 1; i < vs.length; i++) { + expect(vs[i].generation).toBeGreaterThan(vs[i - 1].generation) // strictly ascending + } + expect(vs[vs.length - 1].value).toBeNull() // removed at the end + + // Each version ties to the trusted asOf path. + for (const v of vs) { + const db = await brain.asOf(v.generation) + const viaAsOf = await db.get(a) + await db.release() + if (v.value === null) expect(viaAsOf).toBeNull() + else expect(viaAsOf).toEqual(v.value) + } + + // The distinct user-visible values appear in order. + const versionsWithValue = vs.filter((v) => v.value !== null) + expect(versionsWithValue.map((v) => (v.value as any).metadata?.v)).toEqual([1, 2, 3]) + + // Unknown id → empty history (no throw). + const unknown = await brain.history(uid('hist-nobody')) + expect(unknown.versions).toEqual([]) + }) + + // 6. Composition proof ------------------------------------------------------- + it('composition: "type T changed in (g1, g2]" agrees computed via diff vs via asOf(g2).find ∩ changed', async () => { + const brain = await openMemoryBrain() + const p1 = uid('comp-p1') + const p2 = uid('comp-p2') + const p3 = uid('comp-p3') + const d1 = uid('comp-d1') + + await ( + await brain.transact([ + { op: 'add', id: p1, type: NounType.Person, data: 'p1', vector: vec(1), metadata: { team: 'x' } }, + { op: 'add', id: d1, type: NounType.Document, data: 'd1', vector: vec(2), metadata: { team: 'x' } } + ]) + ).release() + const g1 = brain.generation() + await ( + await brain.transact([ + { op: 'add', id: p2, type: NounType.Person, data: 'p2', vector: vec(3), metadata: { team: 'x' } }, + { op: 'update', id: d1, metadata: { team: 'y' } } + ]) + ).release() + await ( + await brain.transact([ + { op: 'add', id: p3, type: NounType.Person, data: 'p3', vector: vec(4), metadata: { team: 'z' } } + ]) + ).release() + const g2 = brain.generation() + + // Way 1 — from diff: ids that came-into-being or changed in (g1, g2], filtered to Person at g2. + const d = await brain.diff(g1, g2) + const changedNouns = [...d.added.nouns, ...d.modified.nouns] + const dbAtG2 = await brain.asOf(g2) + const way1: string[] = [] + for (const id of changedNouns) { + const e = await dbAtG2.get(id) + if (e?.type === NounType.Person) way1.push(id) + } + + // Way 2 — from find ∩ changed: all Person at g2, intersect with the changed-id set. + const personsAtG2 = await dbAtG2.find({ type: NounType.Person, limit: 100 }) + const changedSet = new Set([...d.added.nouns, ...d.removed.nouns, ...d.modified.nouns]) + const way2 = personsAtG2.map((r) => r.id).filter((id) => changedSet.has(id)) + + expect(way1.sort()).toEqual(way2.sort()) + expect(way1.sort()).toEqual([p2, p3].sort()) // p2 added, p3 added; p1 unchanged; d1 is a Document + + // With a where filter: Person on team 'x' changed in (g1, g2] → just p2. + const personsTeamX = await dbAtG2.find({ type: NounType.Person, where: { team: 'x' }, limit: 100 }) + const teamXChanged = personsTeamX.map((r) => r.id).filter((id) => changedSet.has(id)) + expect(teamXChanged).toEqual([p2]) + + await dbAtG2.release() + }) + + // 7. Granularity ------------------------------------------------------------- + it('granularity: single-operation writes (outside transact) are invisible to the temporal verbs', async () => { + const brain = await openMemoryBrain() + const a = uid('gran-a') + const r1 = await brain.transact([ + { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } } + ]) + await r1.release() + expect((await brain.transactionLog()).length).toBe(1) + + // A single-op write bumps the counter but writes no generation record/log entry. + await brain.update({ id: a, metadata: { v: 2 } }) + + expect((await brain.transactionLog()).length).toBe(1) // unchanged — not logged + const d = await brain.diff(r1.generation, brain.generation()) + expect(d.added.nouns).toEqual([]) // the single-op update is not a recorded change + expect(d.modified.nouns).toEqual([]) + }) + + // 8. Compaction policy contrast --------------------------------------------- + it('compaction: diff/since THROW below the horizon; history TRUNCATES to it', async () => { + const brain = await openMemoryBrain() + const a = uid('comp-a') + for (let v = 1; v <= 6; v++) { + await ( + await brain.transact([ + v === 1 + ? { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(v), metadata: { v } } + : { op: 'update', id: a, metadata: { v } } + ]) + ).release() + } // gens 1..6, all pins released + + await brain.compactHistory({ retainGenerations: 2 }) // raise the horizon + const horizon = generationStoreOf(brain).horizon() + expect(horizon).toBeGreaterThan(0) + + // diff / since reaching below the horizon THROW. + await expect(brain.diff(0, 6)).rejects.toBeInstanceOf(GenerationCompactedError) + const now = brain.now() + await expect(now.since(0)).rejects.toBeInstanceOf(GenerationCompactedError) + await now.release() + + // history TRUNCATES (no throw) — its versions start at or above the horizon. + const h = await brain.history(a) + expect(h.versions.length).toBeGreaterThan(0) + for (const v of h.versions) expect(v.generation).toBeGreaterThanOrEqual(horizon) + }) +}) diff --git a/tests/unit/db/stableEqual.test.ts b/tests/unit/db/stableEqual.test.ts new file mode 100644 index 00000000..bc8f94f0 --- /dev/null +++ b/tests/unit/db/stableEqual.test.ts @@ -0,0 +1,53 @@ +/** + * @module tests/unit/db/stableEqual + * @description Unit proof for the key-order-insensitive deep-equal that + * {@link Brainy.diff} uses to decide whether an id present at both endpoints + * actually changed. The risk it guards: a naive JSON.stringify comparison is + * key-order-sensitive and would report a no-op re-write as `modified`. + */ +import { describe, it, expect } from 'vitest' +import { stableDeepEqual } from '../../../src/db/stableEqual.js' + +describe('stableDeepEqual', () => { + it('ignores object key order (the core diff requirement)', () => { + expect(stableDeepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true) + expect(stableDeepEqual({ a: 1, b: 2, c: { x: 1, y: 2 } }, { c: { y: 2, x: 1 }, b: 2, a: 1 })).toBe(true) + }) + + it('honors array order (arrays are order-significant)', () => { + expect(stableDeepEqual([1, 2, 3], [1, 2, 3])).toBe(true) + expect(stableDeepEqual([1, 2, 3], [3, 2, 1])).toBe(false) + expect(stableDeepEqual([1, 2], [1, 2, 3])).toBe(false) + }) + + it('recurses into nested objects and arrays', () => { + expect( + stableDeepEqual( + { tags: ['a', 'b'], meta: { n: 1, deep: { z: [1, { q: 2 }] } } }, + { meta: { deep: { z: [1, { q: 2 }] }, n: 1 }, tags: ['a', 'b'] } + ) + ).toBe(true) + expect( + stableDeepEqual({ meta: { deep: { z: [1, { q: 2 }] } } }, { meta: { deep: { z: [1, { q: 3 }] } } }) + ).toBe(false) + }) + + it('distinguishes primitives, null, and types', () => { + expect(stableDeepEqual(1, 1)).toBe(true) + expect(stableDeepEqual(1, '1')).toBe(false) + expect(stableDeepEqual(null, null)).toBe(true) + expect(stableDeepEqual(null, {})).toBe(false) + expect(stableDeepEqual({ a: 1 }, { a: 1, b: undefined })).toBe(false) // extra key + expect(stableDeepEqual(true, false)).toBe(false) + }) + + it('treats NaN as equal to NaN (stable value comparison)', () => { + expect(stableDeepEqual(NaN, NaN)).toBe(true) + expect(stableDeepEqual({ x: NaN }, { x: NaN })).toBe(true) + expect(stableDeepEqual(NaN, 0)).toBe(false) + }) + + it('an object and an array of the same length are not equal', () => { + expect(stableDeepEqual({ 0: 'a', 1: 'b' }, ['a', 'b'])).toBe(false) + }) +})