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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue