memorio 4.9.35 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/README.md +307 -359
- package/SECURITY.md +17 -1
- package/SUMMARY.md +59 -45
- package/adr/001-state-proxy-model.md +96 -0
- package/adr/002-observer-semantics.md +180 -0
- package/adr/003-deep-mutation-semantics.md +129 -0
- package/adr/004-array-mutation-semantics.md +128 -0
- package/adr/005-scheduler-contract.md +149 -0
- package/adr/006-context-isolation.md +92 -0
- package/adr/007-mutation-records.md +118 -0
- package/adr/008-transactions.md +106 -0
- package/adr/009-history-model.md +110 -0
- package/adr/README.md +46 -0
- package/adr/template.md +49 -0
- package/examples/basic.ts +115 -115
- package/examples/browser-vanilla.html +358 -358
- package/examples/cache.ts +72 -72
- package/examples/cross-platform-guards.ts +57 -57
- package/examples/history.ts +104 -0
- package/examples/idb.ts +109 -109
- package/examples/multi-tenant-context.ts +44 -44
- package/examples/node-server.ts +308 -308
- package/examples/observer.ts +60 -60
- package/examples/platform.ts +115 -115
- package/examples/react-app.tsx +362 -362
- package/examples/react-observer.tsx +63 -63
- package/examples/semantic-memory.ts +60 -60
- package/examples/session-advanced.ts +91 -91
- package/examples/sqlite-batched-writes.ts +57 -57
- package/examples/state-advanced.ts +89 -89
- package/examples/store-advanced.ts +117 -117
- package/examples/sync.ts +90 -0
- package/examples/typed-and-schema.ts +102 -100
- package/examples/useObserver.tsx +140 -141
- package/global.cjs +4594 -0
- package/global.d.ts +8 -0
- package/global.js +4532 -0
- package/index.cjs +700 -678
- package/index.d.ts +1 -0
- package/index.js +680 -677
- package/llms.txt +72 -4
- package/markdown/AUDIT-REPORT.md +135 -0
- package/markdown/CACHE.md +100 -0
- package/markdown/CHANGELOG.md +243 -0
- package/markdown/DEVTOOLS.md +129 -0
- package/markdown/DISPATCH.md +177 -0
- package/markdown/HISTORY.md +199 -0
- package/markdown/IDB.md +178 -0
- package/markdown/IMPORT.md +153 -0
- package/markdown/INSPECT.md +123 -0
- package/markdown/LOGGER.md +154 -0
- package/markdown/MEMORY-ATTACHMENT.md +96 -0
- package/markdown/MEMORY.md +162 -0
- package/markdown/OBSERVER.md +209 -0
- package/markdown/PLATFORM.md +271 -0
- package/markdown/PROJECT.md +311 -0
- package/markdown/SCHEMA.md +176 -0
- package/markdown/SECURITY.md +330 -0
- package/markdown/SESSION.md +165 -0
- package/markdown/SQLITE.md +190 -0
- package/markdown/STATE.md +160 -0
- package/markdown/STORE.md +171 -0
- package/markdown/SYNC.md +319 -0
- package/markdown/TYPED.md +165 -0
- package/markdown/USEOBSERVER.md +257 -0
- package/modules/redux.cjs +320 -167
- package/modules/redux.cjs.map +1 -1
- package/modules/redux.js +320 -167
- package/modules/redux.js.map +1 -1
- package/package.json +13 -3
- package/types/env.d.ts +19 -9
- package/types/exports.d.ts +20 -0
- package/types/history.d.ts +13 -1
- package/types/memorio.d.ts +17 -5
- package/types/mutation.d.ts +75 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ADR-005: Scheduler Contract
|
|
2
|
+
|
|
3
|
+
> **Status:** Proposed
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
> **Deciders:** Memorio 5.x Core Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
The Memorio 5.x plan (Section 10) requires an explicit, configurable
|
|
10
|
+
scheduler. The 4.7.3 baseline dispatches observer callbacks via
|
|
11
|
+
`queueMicrotask` (in `core/dispatch.ts`). There is no configuration
|
|
12
|
+
point, no batching, and no flush control. This must be formalized into
|
|
13
|
+
a contract that supports future UI-framework integration (React
|
|
14
|
+
batched mode, requestAnimationFrame, manual flush for tests).
|
|
15
|
+
|
|
16
|
+
The scheduler contract must define: ordering, batching, reentrancy,
|
|
17
|
+
error isolation, nested mutations, transaction interaction, and flush
|
|
18
|
+
behavior.
|
|
19
|
+
|
|
20
|
+
## Assumptions
|
|
21
|
+
|
|
22
|
+
- The core engine dispatches events via `dispatch.set(name, value)`.
|
|
23
|
+
- `dispatch.listen(name, cb)` wraps `cb` in a microtask boundary.
|
|
24
|
+
- The scheduler is orthogonal to the Mutation Engine (ADR-007) and
|
|
25
|
+
Transactions (ADR-008) — it only affects *when* observer callbacks
|
|
26
|
+
fire, not *what* is recorded.
|
|
27
|
+
- `__DEV__` is a build-time constant.
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
### Current default: microtask
|
|
32
|
+
|
|
33
|
+
The current dispatch layer uses `queueMicrotask` (or
|
|
34
|
+
`Promise.resolve().then(cb)` fallback). This means:
|
|
35
|
+
|
|
36
|
+
1. **Ordering:** Observer callbacks fire in registration order (FIFO),
|
|
37
|
+
within the microtask queue, after the current synchronous block
|
|
38
|
+
completes.
|
|
39
|
+
2. **Asynchronous:** Callbacks fire after the mutation is committed
|
|
40
|
+
to the raw Proxy target.
|
|
41
|
+
3. **No batching:** Each `dispatch.set` schedules its own microtask.
|
|
42
|
+
|
|
43
|
+
### Proposed scheduler configuration
|
|
44
|
+
|
|
45
|
+
A future `createMemorio({ scheduler: ... })` or
|
|
46
|
+
`memorio.configureScheduler({ mode, batching })` API would allow:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
type SchedulerMode = "sync" | "microtask" | "macrotask" | "raf" | "manual"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Mode | Behavior |
|
|
53
|
+
|------|----------|
|
|
54
|
+
| `sync` | Callbacks fire synchronously during `dispatch.set`. |
|
|
55
|
+
| `microtask` | Callbacks fire in the microtask queue (default). |
|
|
56
|
+
| `macrotask` | Callbacks fire via `setTimeout(cb, 0)` (macro task). |
|
|
57
|
+
| `raf` | Callbacks fire via `requestAnimationFrame`. |
|
|
58
|
+
| `manual` | Callbacks only fire when `flush()` is called. |
|
|
59
|
+
|
|
60
|
+
### Batching semantics (proposed)
|
|
61
|
+
|
|
62
|
+
When `batching: true` (only meaningful with `microtask` or `macrotask`
|
|
63
|
+
mode):
|
|
64
|
+
|
|
65
|
+
1. All `dispatch.set` calls within a single synchronous block are
|
|
66
|
+
collected.
|
|
67
|
+
2. Each unique event name is invoked once, after the synchronous
|
|
68
|
+
block completes, in the scheduled task.
|
|
69
|
+
3. The order of batched callbacks follows the order of first
|
|
70
|
+
`dispatch.set` for each unique event name.
|
|
71
|
+
|
|
72
|
+
When `batching: false` (default):
|
|
73
|
+
|
|
74
|
+
1. Each `dispatch.set` schedules its own callback independently.
|
|
75
|
+
|
|
76
|
+
### Reentrancy
|
|
77
|
+
|
|
78
|
+
- Reentrant `dispatch.set` calls (observer callbacks that dispatch new
|
|
79
|
+
events) are queued after the current callback completes.
|
|
80
|
+
- `sync` mode has no reentrancy boundary — recursive
|
|
81
|
+
`dispatch.set` calls fire immediately, risking stack overflow.
|
|
82
|
+
- No built-in guard against infinite dispatch loops. Consumers must
|
|
83
|
+
break the cycle.
|
|
84
|
+
|
|
85
|
+
### Error isolation
|
|
86
|
+
|
|
87
|
+
- A throwing callback in `microtask`/`macrotask`/`raf` mode produces
|
|
88
|
+
an unhandled rejection / error. Other callbacks scheduled in the same
|
|
89
|
+
tick are unaffected (each runs in its own closure).
|
|
90
|
+
- In `sync` mode, a throwing callback propagates to the caller of
|
|
91
|
+
`dispatch.set`.
|
|
92
|
+
- A future enhancement may wrap each callback in `try/catch` with an
|
|
93
|
+
optional error reporter. This ADR does **not** implement that.
|
|
94
|
+
|
|
95
|
+
### Nested mutations
|
|
96
|
+
|
|
97
|
+
Mutations inside observer callbacks create a new dispatch cycle. They
|
|
98
|
+
are **not** coalesced with the current cycle. This means:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
state.counter = 1
|
|
102
|
+
// microtask fires:
|
|
103
|
+
observer('state.counter', () => {
|
|
104
|
+
state.counter = 2 // → schedules a NEW microtask
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Transaction interaction
|
|
109
|
+
|
|
110
|
+
- During a transaction, each mutation dispatches normally (no
|
|
111
|
+
suppression).
|
|
112
|
+
- `undo()` / `redo()` set `internal.historyEnabled = false` to prevent
|
|
113
|
+
re-recording, but the Proxy set trap still dispatches events to
|
|
114
|
+
observers.
|
|
115
|
+
- A future "transaction-level notification" may suppress individual
|
|
116
|
+
mutation dispatches during a transaction and fire a single
|
|
117
|
+
"transaction-end" event. This ADR does **not** define that.
|
|
118
|
+
|
|
119
|
+
### Flush behavior
|
|
120
|
+
|
|
121
|
+
- In `manual` mode, `memorio.flush()` (or equivalent) processes all
|
|
122
|
+
queued callbacks. This is primarily for testing.
|
|
123
|
+
- In other modes, "flush" is implicit (microtask queue drains
|
|
124
|
+
naturally).
|
|
125
|
+
|
|
126
|
+
## Current status
|
|
127
|
+
|
|
128
|
+
The scheduler is currently **not configurable**. The default
|
|
129
|
+
`microtask` behavior is in effect. This ADR is **Proposed** — the
|
|
130
|
+
configuration API is a Phase 2/3 enhancement. The compliance tests
|
|
131
|
+
for the default behavior lock in the current semantics so that future
|
|
132
|
+
scheduler changes do not break existing code.
|
|
133
|
+
|
|
134
|
+
## Consequences
|
|
135
|
+
|
|
136
|
+
- **Positive:** Documenting the microtask default gives consumers a
|
|
137
|
+
deterministic mental model.
|
|
138
|
+
- **Positive:** The proposed API is framework-agnostic — all modes are
|
|
139
|
+
expressible as platform primitives.
|
|
140
|
+
- **Positive:** `manual` mode enables deterministic testing.
|
|
141
|
+
- **Negative:** No batching means multiple synchronous mutations each
|
|
142
|
+
produce a microtask — this is a known performance limitation for
|
|
143
|
+
bulk updates.
|
|
144
|
+
- **Negative:** No error isolation means a buggy observer can crash
|
|
145
|
+
the app via unhandled rejection.
|
|
146
|
+
|
|
147
|
+
## Compliance tests
|
|
148
|
+
|
|
149
|
+
- `tests/vitest/tests/contracts/adr-005-scheduler.test.ts`
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# ADR-006: Context Isolation Model
|
|
2
|
+
|
|
3
|
+
> **Status:** Accepted
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
> **Deciders:** Memorio 5.x Core Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Memorio must support multiple isolated state trees within a single
|
|
10
|
+
process — for tests, micro-frontends, SSR, workers, and multi-tenant
|
|
11
|
+
environments. The 4.7.3 baseline uses `createContext` /
|
|
12
|
+
`memorio.isolate(name)` to create context-prefixed state, but the
|
|
13
|
+
mechanics and guarantees are undocumented.
|
|
14
|
+
|
|
15
|
+
This ADR defines how context isolation works, what the isolation
|
|
16
|
+
boundary is, and how it interacts with the Mutation Engine (ADR-007).
|
|
17
|
+
|
|
18
|
+
## Assumptions
|
|
19
|
+
|
|
20
|
+
- The core `state`, `store`, `session`, `cache` singletons live on
|
|
21
|
+
`globalThis` and are shared across all import paths (ESM, CJS,
|
|
22
|
+
absolute-path imports).
|
|
23
|
+
- A "context" is a string ID that prefixes internal store keys.
|
|
24
|
+
- `memorio.setContext(id)` sets the active context ID at runtime.
|
|
25
|
+
- Server-side code must create per-request contexts from trusted
|
|
26
|
+
server-side data (never client-controlled input).
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
### Global singleton vs. isolated context
|
|
31
|
+
|
|
32
|
+
- The global `state` / `store` / `session` / `cache` are **singletons**
|
|
33
|
+
on `globalThis`. Importing `memorio` in any module returns the same
|
|
34
|
+
instances. This is the default and is intended for single-instance
|
|
35
|
+
applications (e.g. one browser tab, one Next.js server instance).
|
|
36
|
+
- `memorio.isolate(name)` creates a new context and returns
|
|
37
|
+
context-scoped proxies. These proxies share the same underlying
|
|
38
|
+
engine but read/write to context-prefixed keys in `store`.
|
|
39
|
+
|
|
40
|
+
### Context ID
|
|
41
|
+
|
|
42
|
+
- Context IDs are strings. They must be unique within a process.
|
|
43
|
+
- Context IDs are **organizational**, not a security boundary. They
|
|
44
|
+
do not prevent unauthorized access to another context's data within
|
|
45
|
+
the same process. Server-side authorization must be enforced at the
|
|
46
|
+
application layer.
|
|
47
|
+
- `internal.currentContext` is a module-level string on the singleton
|
|
48
|
+
`internal` object. Mutations record the active context ID.
|
|
49
|
+
|
|
50
|
+
### Context-scoped state
|
|
51
|
+
|
|
52
|
+
When `internal.currentContext` is set to `ctxId`:
|
|
53
|
+
|
|
54
|
+
1. `state.set(key, value)` writes to `state` but records
|
|
55
|
+
`context: ctxId` in the MutationRecord.
|
|
56
|
+
2. `store.set(key, value)` writes to storage with key
|
|
57
|
+
`ctxId:key` (key-prefix isolation).
|
|
58
|
+
3. `session.set(key, value)` writes to storage with key
|
|
59
|
+
`ctxId:key`.
|
|
60
|
+
4. Reads from `store.get(key)` and `session.get(key)` read from
|
|
61
|
+
`ctxId:key`.
|
|
62
|
+
|
|
63
|
+
### Default context
|
|
64
|
+
|
|
65
|
+
When `internal.currentContext` is `null` (the default), all state and
|
|
66
|
+
store operations use the un-prefixed keys. This is the "default" or
|
|
67
|
+
"global" context.
|
|
68
|
+
|
|
69
|
+
### Multi-tenant caveat
|
|
70
|
+
|
|
71
|
+
`state` (the Proxy) is always global — it is a single reactive tree.
|
|
72
|
+
Context isolation applies to **persistence** (`store`, `session`) and
|
|
73
|
+
**mutation records** (`context` field). To achieve true state-tree
|
|
74
|
+
isolation, consumers should use `memorio.isolate(name)` which creates
|
|
75
|
+
a new context, or instantiate isolated Memorio instances.
|
|
76
|
+
|
|
77
|
+
## Consequences
|
|
78
|
+
|
|
79
|
+
- **Positive:** Single global singleton simplifies the common case
|
|
80
|
+
(one app, one state tree).
|
|
81
|
+
- **Positive:** Context-prefixed persistence enables multi-tenant
|
|
82
|
+
server-side usage without separate state trees.
|
|
83
|
+
- **Positive:** Mutation records carry context, enabling causal-graph
|
|
84
|
+
partitioning (Phase 4).
|
|
85
|
+
- **Negative:** `state` itself is not multi-tenant — consumers needing
|
|
86
|
+
isolated state trees must use `isolate()` or separate processes.
|
|
87
|
+
- **Negative:** Context isolation is organizational, not a security
|
|
88
|
+
boundary — documented clearly in AGENTS.md.
|
|
89
|
+
|
|
90
|
+
## Compliance tests
|
|
91
|
+
|
|
92
|
+
- `tests/vitest/tests/contracts/adr-006-context.test.ts`
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# ADR-007: Mutation Records
|
|
2
|
+
|
|
3
|
+
> **Status:** Accepted
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
> **Deciders:** Memorio 5.x Core Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Section 6 of the Memorio 5.x plan requires every state mutation to pass
|
|
10
|
+
through a normalized internal `MutationRecord`. The 4.7.3 baseline has
|
|
11
|
+
an informal `MutationRecord` interface in `core/internal.ts` with
|
|
12
|
+
`path`, `action`, `newValue`, `previousValue`, `timestamp`. The 5.0
|
|
13
|
+
codebase has extended this with `id`, `operation`, `hlc`, `context`,
|
|
14
|
+
`source`, and `transactionId` (see `core/mutation/types.ts`).
|
|
15
|
+
|
|
16
|
+
This ADR formalizes the canonical record structure and guarantees.
|
|
17
|
+
|
|
18
|
+
## Assumptions
|
|
19
|
+
|
|
20
|
+
- Every `set`/`delete` trap on the state Proxy calls the
|
|
21
|
+
`buildProxy` callback once.
|
|
22
|
+
- The callback constructs a `Mutation` via `createMutation()` which:
|
|
23
|
+
- Generates an HLC-based ID (`m_<encoded>`).
|
|
24
|
+
- Captures `before` = previous value, `after` = new value.
|
|
25
|
+
- Tags the mutation with the active transaction ID (if any).
|
|
26
|
+
- Sets `context` from `internal.currentContext`.
|
|
27
|
+
- `recordMutation()` is the single write point to the trace log and
|
|
28
|
+
undo/redo stacks (in `core/internal.ts`).
|
|
29
|
+
- History tracking is opt-in via `enableHistory(true)`.
|
|
30
|
+
|
|
31
|
+
## Decision
|
|
32
|
+
|
|
33
|
+
### Canonical MutationRecord structure
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface MutationRecord {
|
|
37
|
+
// --- Canonical Memorio 5 fields ---
|
|
38
|
+
id: string // HLC-based UUID: "m_<encoded-hlc>"
|
|
39
|
+
path: string // Dotted path relative to state: "user.profile.name"
|
|
40
|
+
operation: 'set' | 'delete' | 'insert' | 'remove' | 'replace' | 'length'
|
|
41
|
+
before: any // Value before mutation (deepRaw'd)
|
|
42
|
+
after: any // Value after mutation (deepRaw'd)
|
|
43
|
+
timestamp: number // Epoch ms
|
|
44
|
+
hlc: string // Serialized HLC: "hlc:<physical>:<logical>:<node>"
|
|
45
|
+
|
|
46
|
+
// --- Metadata ---
|
|
47
|
+
context?: string // Active context ID
|
|
48
|
+
source?: string // Optional attribution (e.g. "profile.save")
|
|
49
|
+
transactionId?: string // Active transaction ID, if any
|
|
50
|
+
parentId?: string // Parent mutation ID (for causal graph)
|
|
51
|
+
|
|
52
|
+
// --- Legacy fields (backward compatibility) ---
|
|
53
|
+
action: 'set' | 'delete' // Mirrors operation (set/delete only)
|
|
54
|
+
newValue: any // Alias for `after`
|
|
55
|
+
previousValue: any // Alias for `before`
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Guarantees
|
|
60
|
+
|
|
61
|
+
1. **Every mutation gets a unique ID.** The ID is generated via
|
|
62
|
+
`generateMutationId()` which calls `hlc.tick()` — guaranteeing
|
|
63
|
+
causal ordering within a single node. The node suffix ensures
|
|
64
|
+
uniqueness across devices.
|
|
65
|
+
|
|
66
|
+
2. **Before/after are deepRaw'd.** Proxy wrappers are never stored in
|
|
67
|
+
the record — only plain values. This ensures serializable,
|
|
68
|
+
comparison-safe records.
|
|
69
|
+
|
|
70
|
+
3. **Single write point.** `internal.recordMutation()` is the only
|
|
71
|
+
function that pushes to `mutations`, `undoStack`, or `redoStack`.
|
|
72
|
+
No other code path mutates these arrays directly.
|
|
73
|
+
|
|
74
|
+
4. **Redo stack clears on new mutation.** Any mutation (including
|
|
75
|
+
undo/redo inverse writes, since those temporarily disable history)
|
|
76
|
+
must NOT clear the redo stack — undo/redo use
|
|
77
|
+
`internal.historyEnabled = false` to bypass `recordMutation`.
|
|
78
|
+
|
|
79
|
+
5. **Max history depth.** Both stacks are trimmed to
|
|
80
|
+
`internal.maxHistory` (default: 100). Trimming removes from the
|
|
81
|
+
front (shift), preserving recency.
|
|
82
|
+
|
|
83
|
+
6. **No-op mutations.** If `before === after`, the record is still
|
|
84
|
+
created and stored (the mutation happened, even if the value
|
|
85
|
+
didn't change). `mutationToPatch()` returns `null` for no-ops, so
|
|
86
|
+
no patch is stored in a commit's patch list.
|
|
87
|
+
|
|
88
|
+
### Reentrancy guard
|
|
89
|
+
|
|
90
|
+
The `mutate()` function sets `internal._recording = false` before
|
|
91
|
+
writing to the state Proxy, then calls `recordMutation()` itself. This
|
|
92
|
+
prevents the Proxy callback from double-recording the same mutation.
|
|
93
|
+
|
|
94
|
+
### Transaction tagging
|
|
95
|
+
|
|
96
|
+
During an active transaction, `createMutation()` calls
|
|
97
|
+
`tagForTransaction()` to attach the current transaction ID.
|
|
98
|
+
`registerMutationInTransaction(id)` adds the mutation ID to the
|
|
99
|
+
transaction's mutation list.
|
|
100
|
+
|
|
101
|
+
## Consequences
|
|
102
|
+
|
|
103
|
+
- **Positive:** A single, well-typed record structure serves undo,
|
|
104
|
+
redo, trace, diff, and future replay/revert.
|
|
105
|
+
- **Positive:** HLC-based IDs provide causal ordering for the causal
|
|
106
|
+
graph and simulation engine.
|
|
107
|
+
- **Positive:** Legacy fields maintain full backward compatibility
|
|
108
|
+
with 4.x consumers.
|
|
109
|
+
- **Negative:** Storing both legacy and canonical fields doubles the
|
|
110
|
+
record size — acceptable for the opt-in history feature.
|
|
111
|
+
- **Negative:** No-op mutations are stored — a future optimization
|
|
112
|
+
could skip them in the set trap, but the Mutation Engine must remain
|
|
113
|
+
the source of truth.
|
|
114
|
+
|
|
115
|
+
## Compliance tests
|
|
116
|
+
|
|
117
|
+
- `tests/vitest/tests/contracts/adr-007-mutation-records.test.ts`
|
|
118
|
+
- `tests/vitest/tests/mutation-engine.test.ts` (existing)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# ADR-008: Transactions
|
|
2
|
+
|
|
3
|
+
> **Status:** Accepted
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
> **Deciders:** Memorio 5.x Core Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Section 11 of the Memorio 5.x plan requires transactions to group
|
|
10
|
+
multiple mutations into one logical operation. The 5.0 codebase
|
|
11
|
+
implements `beginTransaction`, `commitTransaction`,
|
|
12
|
+
`abortTransaction`, `listTransactions`, `currentTransaction` in
|
|
13
|
+
`core/mutation/transaction.ts`. This ADR formalizes the semantics.
|
|
14
|
+
|
|
15
|
+
## Assumptions
|
|
16
|
+
|
|
17
|
+
- Mutations inside a transaction are tagged with the transaction ID
|
|
18
|
+
via `createMutation()` → `tagForTransaction()`.
|
|
19
|
+
- The transaction registry lives on `globalThis`
|
|
20
|
+
(`__memorio_transaction_registry__`) to survive module duplication.
|
|
21
|
+
- `internal.currentTransactionId` tracks the active transaction.
|
|
22
|
+
- `memorio.transaction(fn, opts)` is a convenience alias for
|
|
23
|
+
`beginTransaction`.
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
### Transaction lifecycle
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
beginTransaction(source, message, metadata)
|
|
31
|
+
→ creates TransactionContext { id, startedAt, active: true }
|
|
32
|
+
→ sets internal.currentTransactionId = id
|
|
33
|
+
→ mutations tagged with id
|
|
34
|
+
|
|
35
|
+
commitTransaction()
|
|
36
|
+
→ marks context active: false, sets committedAt
|
|
37
|
+
→ clears internal.currentTransactionId
|
|
38
|
+
→ returns { id, mutations[], startedAt, committedAt, source, message, metadata }
|
|
39
|
+
|
|
40
|
+
abortTransaction()
|
|
41
|
+
→ rolls back all tagged mutations (applies inverse)
|
|
42
|
+
→ removes mutation IDs from undoStack (no redo push)
|
|
43
|
+
→ deletes from registry, clears currentTransactionId
|
|
44
|
+
→ returns the Transaction record
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Transaction ID generation
|
|
48
|
+
|
|
49
|
+
- Generated via `generateTransactionId()` → `tx_<encoded-hlc>`.
|
|
50
|
+
- Monotonically ordered within a node via HLC.
|
|
51
|
+
|
|
52
|
+
### Nested transactions
|
|
53
|
+
|
|
54
|
+
`beginTransaction` called while a transaction is already active
|
|
55
|
+
creates a **new** transaction context (with a new ID). The previous
|
|
56
|
+
transaction remains active in the registry — it is **not** suspended
|
|
57
|
+
or paused. `commitTransaction()` only commits the **most recently
|
|
58
|
+
started** active transaction.
|
|
59
|
+
|
|
60
|
+
This is a **flat namespace** model: transactions are independent, not
|
|
61
|
+
hierarchical. A mutation made after the inner `beginTransaction` is
|
|
62
|
+
tagged with the inner transaction's ID, not the outer.
|
|
63
|
+
|
|
64
|
+
> **Future:** A hierarchical model (with parentId) may be introduced.
|
|
65
|
+
> This ADR documents the current flat behavior.
|
|
66
|
+
|
|
67
|
+
### Rollback on abort
|
|
68
|
+
|
|
69
|
+
`abortTransaction` finds each tagged mutation in `undoStack` by its
|
|
70
|
+
record ID, removes it from the stack (without pushing to redo), and
|
|
71
|
+
applies the inverse (`_applyInverse`). The inverse writes go through
|
|
72
|
+
the Proxy with `historyEnabled = false` and `_recording = false`, so
|
|
73
|
+
they are not recorded.
|
|
74
|
+
|
|
75
|
+
### Transaction record immutability
|
|
76
|
+
|
|
77
|
+
Once committed or aborted, a transaction's `mutations` list is frozen
|
|
78
|
+
(immutable). The registry retains the context for
|
|
79
|
+
`listTransactions()` inspection, but `active` is `false`.
|
|
80
|
+
|
|
81
|
+
### Mutate inside a transaction
|
|
82
|
+
|
|
83
|
+
`memorio.mutate(path, value, { source })` inside a transaction:
|
|
84
|
+
1. Calls `createMutation(...)` which auto-tags with the active
|
|
85
|
+
transaction ID via `tagForTransaction()`.
|
|
86
|
+
2. Writes to state with `_recording = false` (no double recording).
|
|
87
|
+
3. Calls `recordMutation(mutation)` — the mutation is recorded in the
|
|
88
|
+
trace log AND tagged in the transaction.
|
|
89
|
+
|
|
90
|
+
## Consequences
|
|
91
|
+
|
|
92
|
+
- **Positive:** Mutations are grouped and attributable to a logical
|
|
93
|
+
operation — essential for explain() and impact() in Phase 4.
|
|
94
|
+
- **Positive:** `abortTransaction` provides atomic rollback — all
|
|
95
|
+
mutations are undone.
|
|
96
|
+
- **Positive:** Registry on `globalThis` survives module duplication.
|
|
97
|
+
- **Negative:** Nested transactions are flat (not hierarchical) —
|
|
98
|
+
the outer transaction is not aborted if the inner is aborted.
|
|
99
|
+
- **Negative:** Rollback via inverse application requires all
|
|
100
|
+
`before` values to be present in the MutationRecord — for
|
|
101
|
+
very large objects this is memory-intensive.
|
|
102
|
+
|
|
103
|
+
## Compliance tests
|
|
104
|
+
|
|
105
|
+
- `tests/vitest/tests/contracts/adr-008-transactions.test.ts`
|
|
106
|
+
- `tests/vitest/tests/mutation-engine.test.ts` (existing, §06-07)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# ADR-009: History Model (Snapshot / Delta)
|
|
2
|
+
|
|
3
|
+
> **Status:** Proposed
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
> **Deciders:** Memorio 5.x Core Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Section 12 and Section 13 of the Memorio 5.x plan require an immutable,
|
|
10
|
+
Git-inspired history model with commits, snapshots, deltas, and undo/redo
|
|
11
|
+
as distinct concepts. The 4.7.3 baseline implements undo/redo on a
|
|
12
|
+
linear mutation stack (in `core/internal.ts` and
|
|
13
|
+
`functions/history/index.ts`). This ADR defines the upgrade path to
|
|
14
|
+
the full Commit → Snapshot → Delta model.
|
|
15
|
+
|
|
16
|
+
## Assumptions
|
|
17
|
+
|
|
18
|
+
- The current undo/redo stack stores flat `MutationRecord` entries.
|
|
19
|
+
- `enableHistory(true)` activates recording.
|
|
20
|
+
- `memorio.snapshot()` captures a deep clone of state.
|
|
21
|
+
- `memorio.trace()` returns the flat mutation log.
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
### Current state (4.7.3 baseline)
|
|
26
|
+
|
|
27
|
+
- Mutations are stored as `MutationRecord[]` in `undoStack` and
|
|
28
|
+
`redoStack` on the singleton `internal` object.
|
|
29
|
+
- `undo()` pops from `undoStack`, pushes to `redoStack`, applies the
|
|
30
|
+
inverse to state.
|
|
31
|
+
- `redo()` pops from `redoStack`, pushes to `undoStack`, re-applies.
|
|
32
|
+
- `trace()` returns the full `mutations` log (trimmed to
|
|
33
|
+
`maxHistory`).
|
|
34
|
+
- No commit concept exists — every mutation is an individual entry.
|
|
35
|
+
|
|
36
|
+
### Target state (Memorio 5.0)
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Commit (immutable, append-only)
|
|
40
|
+
├── parent: CommitId | null
|
|
41
|
+
├── patches: Patch[] (RFC 6902: add | remove | replace)
|
|
42
|
+
├── metadata: { source?, message?, actor?, timestamp, hlc }
|
|
43
|
+
└── snapshot?: SnapshotRef (materialized every N commits)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **Commits** are the unit of history. `memorio.commit(message, fn)`
|
|
47
|
+
groups all mutations in `fn` into one commit.
|
|
48
|
+
- **Snapshots** are deep clones of state, materialized every
|
|
49
|
+
`snapshotInterval` commits (default: 100) to bound replay cost.
|
|
50
|
+
- **Deltas** are the `Patch[]` that transform a snapshot to the next
|
|
51
|
+
state. Only patches are stored between snapshots — never full
|
|
52
|
+
state copies.
|
|
53
|
+
- **Undo/Redo** operate at the commit level, not the individual
|
|
54
|
+
mutation level. Undoing a commit applies the inverse of every patch
|
|
55
|
+
in the commit.
|
|
56
|
+
|
|
57
|
+
### Migration path
|
|
58
|
+
|
|
59
|
+
1. **Phase 1 (this sprint):** Keep the existing flat mutation stack.
|
|
60
|
+
Document that `undo()`/`redo()` operate per-mutation, not per-commit.
|
|
61
|
+
Add `memorio.commit()` as a grouping mechanism without changing the
|
|
62
|
+
underlying stack.
|
|
63
|
+
2. **Phase 3:** Replace the flat stack with the Commit → Snapshot →
|
|
64
|
+
Delta model. `undo()`/`redo()` will operate per-commit.
|
|
65
|
+
|
|
66
|
+
### Undo vs. Revert (explicit distinction)
|
|
67
|
+
|
|
68
|
+
- **Undo** (`memorio.undo()`): Moves the current position backward in
|
|
69
|
+
the undo stack. The mutation is preserved on the redo stack.
|
|
70
|
+
- **Revert** (`memorio.history.revert(commitId)`): Creates a **new**
|
|
71
|
+
mutation that reverses a historical commit. The original commit
|
|
72
|
+
remains in history (immutable). This is a future Phase 3 feature.
|
|
73
|
+
|
|
74
|
+
### maxCommits and maxHistory
|
|
75
|
+
|
|
76
|
+
- `maxCommits` (default: 10,000): Maximum number of commit entries
|
|
77
|
+
retained. Older commits are trimmed (FIFO).
|
|
78
|
+
- `maxHistory` (default: 100): Maximum mutations per undo/redo stack
|
|
79
|
+
(legacy, applies to the current flat model).
|
|
80
|
+
|
|
81
|
+
### Replay safety
|
|
82
|
+
|
|
83
|
+
- Replay must be deterministic. Patches are pure (no closures, no
|
|
84
|
+
functions). `state.path = value` is the inverse of `delete
|
|
85
|
+
state.path` in a patch-based replay.
|
|
86
|
+
- External dependencies (Date.now, Math.random, crypto) must be
|
|
87
|
+
injected via a replay context to maintain determinism.
|
|
88
|
+
|
|
89
|
+
## Consequences
|
|
90
|
+
|
|
91
|
+
- **Positive:** Commits group mutations logically — essential for
|
|
92
|
+
explain(), business history, and audit trails.
|
|
93
|
+
- **Positive:** Snapshot/delta model bounds memory and replay cost.
|
|
94
|
+
- **Positive:** Undo and Revert are distinct operations — no
|
|
95
|
+
conflation.
|
|
96
|
+
- **Negative:** The current flat stack must be replaced in Phase 3,
|
|
97
|
+
which is a breaking change to `undo()`/`redo()` semantics
|
|
98
|
+
(per-mutation → per-commit).
|
|
99
|
+
- **Negative:** Snapshot materialization at intervals is a trade-off
|
|
100
|
+
between memory and CPU — defaults must be benchmark-driven.
|
|
101
|
+
|
|
102
|
+
## Compliance tests
|
|
103
|
+
|
|
104
|
+
- `tests/vitest/tests/contracts/adr-009-history.test.ts`
|
|
105
|
+
- `tests/vitest/tests/mutation-engine.test.ts` (existing, §04-07)
|
|
106
|
+
|
|
107
|
+
## Benchmarks
|
|
108
|
+
|
|
109
|
+
- `experimental/benchmarks/replay.bench.ts` — measures replay cost
|
|
110
|
+
with and without interval snapshots.
|
package/adr/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Architecture Decision Records (ADRs)
|
|
2
|
+
|
|
3
|
+
This directory contains Architecture Decision Records (ADRs) for the
|
|
4
|
+
**Memorio 5.x** "Application State Intelligence Runtime" initiative.
|
|
5
|
+
|
|
6
|
+
## Format
|
|
7
|
+
|
|
8
|
+
ADRs follow the [MADR](https://adr.github.io/) (Markdown Architectural
|
|
9
|
+
Decision Records) format. Each decision record answers four questions:
|
|
10
|
+
|
|
11
|
+
1. **Context** — What is the issue that we were dealing with when we
|
|
12
|
+
started thinking about it?
|
|
13
|
+
2. **Decision** — What is the change that we will make (that resolves
|
|
14
|
+
the issue)?
|
|
15
|
+
3. **Consequences** — What becomes easier or more difficult to do
|
|
16
|
+
because of this change?
|
|
17
|
+
|
|
18
|
+
## Numbering
|
|
19
|
+
|
|
20
|
+
| ADR | Title | Status |
|
|
21
|
+
|-----|-------|--------|
|
|
22
|
+
| [001](001-state-proxy-model.md) | State Proxy Model | Accepted |
|
|
23
|
+
| [002](002-observer-semantics.md) | Observer Semantics | Proposed |
|
|
24
|
+
| [003](003-deep-mutation-semantics.md) | Deep Mutation Semantics | Proposed |
|
|
25
|
+
| [004](004-array-mutation-semantics.md) | Array Mutation Semantics | Proposed |
|
|
26
|
+
| [005](005-scheduler-contract.md) | Scheduler Contract | Proposed |
|
|
27
|
+
| [006](006-context-isolation.md) | Context Isolation Model | Accepted |
|
|
28
|
+
| [007](007-mutation-records.md) | Mutation Records | Accepted |
|
|
29
|
+
| [008](008-transactions.md) | Transactions | Accepted |
|
|
30
|
+
| [009](009-history-model.md) | History Model (Snapshot/Delta) | Proposed |
|
|
31
|
+
|
|
32
|
+
## Lifecycle
|
|
33
|
+
|
|
34
|
+
New ADRs start as **Proposed**, move to **Accepted** when the decision
|
|
35
|
+
is implemented and tests pass, and become **Superseded** when a later
|
|
36
|
+
ADR supersedes them. A **Deprecated** status marks decisions that are no
|
|
37
|
+
longer relevant but kept for historical context.
|
|
38
|
+
|
|
39
|
+
## Compliance
|
|
40
|
+
|
|
41
|
+
Every ADR that defines a behavioral contract must have a corresponding
|
|
42
|
+
compliance test suite under `tests/vitest/tests/contracts/`. The test
|
|
43
|
+
file name mirrors the ADR number (e.g. `adr-004-arrays.test.ts`).
|
|
44
|
+
|
|
45
|
+
A release must not be published if any **Accepted** ADR has failing
|
|
46
|
+
compliance tests.
|
package/adr/template.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# ADR Template — MADR format
|
|
2
|
+
|
|
3
|
+
> **Status:** `{Proposed | Accepted | Superseded by [ADR-XXX](adr-XXX.md) | Deprecated}`
|
|
4
|
+
> **Date:** YYYY-MM-DD
|
|
5
|
+
> **Deciders:** Memorio 5.x Core Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
> What is the issue that we were dealing with when we started thinking
|
|
10
|
+
> about it? What are the forces at play? Consider the unique constraints
|
|
11
|
+
> of this project — the need for deterministic, explainable state that
|
|
12
|
+
> remembers, understands, replays, and simulates application state.
|
|
13
|
+
|
|
14
|
+
## Assumptions
|
|
15
|
+
|
|
16
|
+
> List any assumptions the decision relies on (e.g. “state mutations are
|
|
17
|
+
> synchronous,” “the Proxy target is always a plain object or array”).
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
> What is the change that we will make? Describe the chosen approach in
|
|
22
|
+
> detail. This section must be precise enough to serve as an implementation
|
|
23
|
+
> contract. Include:
|
|
24
|
+
>
|
|
25
|
+
> - The precise semantics (what happens, what does not happen)
|
|
26
|
+
> - Edge cases and error behavior
|
|
27
|
+
> - Performance characteristics
|
|
28
|
+
> - Backward-compatibility implications
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
> What becomes easier or more difficult to do because of this change?
|
|
33
|
+
> Consider:
|
|
34
|
+
>
|
|
35
|
+
> - **Positive** — benefits gained (e.g. improved developer experience,
|
|
36
|
+
> better performance, deterministic behavior)
|
|
37
|
+
> - **Negative** — trade-offs and drawbacks (e.g. loss of flexibility,
|
|
38
|
+
> migration complexity, runtime overhead)
|
|
39
|
+
> - **Testability** — how this decision is covered by compliance tests
|
|
40
|
+
|
|
41
|
+
## Compliance tests
|
|
42
|
+
|
|
43
|
+
> Reference the test file(s) that verify this contract. Every Accepted
|
|
44
|
+
> ADR must list at least one compliance test file path relative to the
|
|
45
|
+
> repository root.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
File: _ADR_TITLE_PLACEHOLDER.md
|