feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window
asOf() answers "state AT a point"; these answer "what happened BETWEEN two
points" and "one entity's whole history", all on the existing generation records.
- Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp}
chokepoint (reachability + Date semantics identical everywhere). asOf()'s
inclusive path is byte-identical — db-mvcc still 25/25.
- asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0,
never a RangeError).
- db.since(Db | generation | Date) — overload; number/Date resolve via the shared
resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound;
since(db) === since(db.generation). Same-store guard via Db.belongsToStore.
- brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its
name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by
existence at both endpoints + a key-order-insensitive value compare
(new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in
no bucket.
- brain.history(id, { from, to }) → every distinct version oldest→newest, each
value === asOf(version.generation).get(id); null = removal; kind auto-detected
(throws on UUID-space collision). New store helper generationsTouching().
- brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window
(contrast since's exclusive lower bound); limit applied last.
- Compaction policy locked: diff/since THROW GenerationCompactedError below the
horizon; history TRUNCATES to it.
Types DiffResult/HistoryVersion/EntityHistory exported from the package root.
Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name,
history↔asOf cross-check, the composition proof, granularity, compaction contrast)
+ tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md
+ RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
This commit is contained in:
parent
373a48122d
commit
2c84f86815
10 changed files with 1069 additions and 37 deletions
27
RELEASES.md
27
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
331
src/brainy.ts
331
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<T = any> implements BrainyInterface<T> {
|
|||
* `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<TxLogEntry[]> {
|
||||
async transactionLog(options?: {
|
||||
from?: number | Date
|
||||
to?: number | Date
|
||||
limit?: number
|
||||
}): Promise<TxLogEntry[]> {
|
||||
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<T = any> implements BrainyInterface<T> {
|
|||
* @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<PortableGraph> {
|
||||
return this.now().export(selector, options)
|
||||
|
|
@ -5196,6 +5230,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* 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<T = any> implements BrainyInterface<T> {
|
|||
* @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<Db<T>> {
|
||||
async asOf(
|
||||
target: number | Date | string,
|
||||
options?: { exclusive?: boolean }
|
||||
): Promise<Db<T>> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
if (typeof target === 'string') {
|
||||
|
|
@ -5219,21 +5262,278 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return Brainy.load<T>(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<T>, b: number | Date | Db<T>): Promise<DiffResult> {
|
||||
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<EntityHistory<T>> {
|
||||
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<T>[] = []
|
||||
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<T> | Relation<T> | 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<T>): Promise<number> {
|
||||
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<number> {
|
||||
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<T = any> implements BrainyInterface<T> {
|
|||
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),
|
||||
|
|
|
|||
73
src/db/db.ts
73
src/db/db.ts
|
|
@ -116,6 +116,16 @@ export interface DbHost<T = any> {
|
|||
find(query: string | FindParams<T>): Promise<Result<T>[]>
|
||||
/** Live `brain.related()` (current-generation fast path). */
|
||||
related(paramsOrId?: string | RelatedParams): Promise<Relation<T>[]>
|
||||
/**
|
||||
* 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<T = any> {
|
|||
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<T = any> {
|
|||
|
||||
/**
|
||||
* @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<T>): Promise<ChangedIds> {
|
||||
async since(lowerBound: Db<T> | number | Date): Promise<ChangedIds> {
|
||||
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)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
|
|||
|
|
@ -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<number[]> {
|
||||
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()`.
|
||||
|
|
|
|||
64
src/db/stableEqual.ts
Normal file
64
src/db/stableEqual.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
const bo = b as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
|
|
@ -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<T = any> {
|
||||
/** 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<T> | Relation<T> | 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<T = any> {
|
||||
/** 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<T>[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Persisted record shapes
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
381
tests/integration/db-temporal.test.ts
Normal file
381
tests/integration/db-temporal.test.ts
Normal file
|
|
@ -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<Brainy> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
53
tests/unit/db/stableEqual.test.ts
Normal file
53
tests/unit/db/stableEqual.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue