memorio 4.9.35 → 5.1.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 +95 -516
- package/SECURITY.md +159 -33
- package/SUMMARY.md +59 -45
- package/adr/001-state-proxy-model.md +95 -0
- package/adr/002-observer-semantics.md +179 -0
- package/adr/003-deep-mutation-semantics.md +128 -0
- package/adr/004-array-mutation-semantics.md +127 -0
- package/adr/005-scheduler-contract.md +148 -0
- package/adr/006-context-isolation.md +91 -0
- package/adr/007-mutation-records.md +117 -0
- package/adr/008-transactions.md +105 -0
- package/adr/009-history-model.md +109 -0
- package/adr/README.md +46 -0
- package/adr/template.md +48 -0
- package/bin/cli.js +68 -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 +5733 -0
- package/global.d.ts +8 -0
- package/global.js +5667 -0
- package/index.cjs +2202 -1041
- package/index.d.ts +2 -0
- package/index.js +2178 -1040
- package/llms.txt +113 -8
- package/markdown/AUDIT-REPORT.md +134 -0
- package/markdown/CACHE.md +191 -0
- package/markdown/DEVTOOLS.md +128 -0
- package/markdown/DISPATCH.md +176 -0
- package/markdown/HISTORY.md +198 -0
- package/markdown/IDB.md +177 -0
- package/markdown/IMPORT.md +152 -0
- package/markdown/INSPECT.md +122 -0
- package/markdown/LOGGER.md +153 -0
- package/markdown/MEMORY-ATTACHMENT.md +95 -0
- package/markdown/MEMORY.md +161 -0
- package/markdown/OBSERVER.md +208 -0
- package/markdown/PLATFORM.md +277 -0
- package/markdown/REDUX.md +54 -0
- package/markdown/SCHEMA.md +175 -0
- package/markdown/SESSION.md +164 -0
- package/markdown/SQLITE.md +189 -0
- package/markdown/STATE.md +159 -0
- package/markdown/STORE.md +170 -0
- package/markdown/SYNC.md +318 -0
- package/markdown/TYPED.md +164 -0
- package/markdown/USEOBSERVER.md +256 -0
- package/modules/redux.cjs +701 -177
- package/modules/redux.cjs.map +1 -1
- package/modules/redux.js +701 -177
- package/modules/redux.js.map +1 -1
- package/package.json +26 -4
- package/types/broadcast.d.ts +61 -0
- package/types/computed.d.ts +96 -0
- package/types/encryption.d.ts +129 -0
- package/types/env.d.ts +19 -9
- package/types/exports.d.ts +29 -0
- package/types/history.d.ts +13 -1
- package/types/memorio.d.ts +25 -6
- package/types/mutation.d.ts +75 -0
- package/types/security.d.ts +67 -0
- package/types/session.d.ts +23 -5
- package/types/store.d.ts +19 -3
- package/vsix/memorio.vsix +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# ADR-003: Deep Mutation Semantics
|
|
2
|
+
|
|
3
|
+
> **Status:** Proposed
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Deep mutations (e.g. `state.user.profile.name = "Alice"`) are the most
|
|
9
|
+
common source of subtle bugs in proxy-based state libraries. The
|
|
10
|
+
4.7.3 baseline had forensic defects around deep mutation notifications:
|
|
11
|
+
observers not firing, paths not resolved correctly, payloads missing
|
|
12
|
+
arguments. While these defects were reported as not reproducible in
|
|
13
|
+
repo v5.1.0, the semantics remain **undocumented** - they rely on
|
|
14
|
+
accidental Proxy behavior rather than an explicit contract.
|
|
15
|
+
|
|
16
|
+
This ADR makes the deep mutation semantics explicit.
|
|
17
|
+
|
|
18
|
+
## Assumptions
|
|
19
|
+
|
|
20
|
+
- `buildProxy` recursively wraps nested plain objects and arrays.
|
|
21
|
+
- Each Proxy tracks its dotted path via the `tree` array.
|
|
22
|
+
- The set trap computes `path = objPath(key, tree)` - a dotted path
|
|
23
|
+
relative to `state` (e.g. `user.profile.name`).
|
|
24
|
+
- The callback receives `{ action, path, newValue, previousValue }`.
|
|
25
|
+
- The proxy stores **raw** values (via `deepRaw`) on the raw target.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
### Deep mutation notification scope
|
|
30
|
+
|
|
31
|
+
When `state.user.profile.name = "Alice"` executes:
|
|
32
|
+
|
|
33
|
+
1. The set trap on the deepest proxy (the `profile` object) fires.
|
|
34
|
+
2. `path` is computed as `user.profile.name` (relative to `state`).
|
|
35
|
+
3. The callback receives `{ path: "user.profile.name", ... }`.
|
|
36
|
+
4. `dispatch.set("state.user.profile.name", ...)` is called.
|
|
37
|
+
5. **Only** observers on `state.user.profile.name` fire.
|
|
38
|
+
6. `state.user.profile.name` is updated on the raw target to `"Alice"`.
|
|
39
|
+
|
|
40
|
+
Observers on `state.user.profile`, `state.user`, or `state` do **not**
|
|
41
|
+
receive a notification for this mutation. This is the exact-path
|
|
42
|
+
contract (see ADR-002, §1).
|
|
43
|
+
|
|
44
|
+
### Reference identity after deep mutation
|
|
45
|
+
|
|
46
|
+
After `state.user.profile.name = "Alice"`:
|
|
47
|
+
- `state.user` returns the **same** proxy wrapper (cached via WeakMap).
|
|
48
|
+
- `state.user.profile` returns the **same** proxy wrapper.
|
|
49
|
+
- `state.user.profile.name` returns `"Alice"` (the new primitive).
|
|
50
|
+
- `state.user.profile[TARGET]` returns the same raw object as before
|
|
51
|
+
(the object was mutated in place, not replaced).
|
|
52
|
+
|
|
53
|
+
### Intermediate object creation
|
|
54
|
+
|
|
55
|
+
If a deep path does not exist yet (`state.user.profile` is undefined),
|
|
56
|
+
and a consumer writes `state.user.profile = { name: "Alice" }`:
|
|
57
|
+
1. The set trap on `user` fires with `path = "user.profile"`.
|
|
58
|
+
2. The full object `{ name: "Alice" }` is stored as the raw value.
|
|
59
|
+
3. A new proxy wrapper is created for `{ name: "Alice" }` on the next
|
|
60
|
+
read.
|
|
61
|
+
4. The event dispatched is `state.user.profile` - **not**
|
|
62
|
+
`state.user.profile.name`.
|
|
63
|
+
|
|
64
|
+
There is **no automatic intermediate object creation** during deep
|
|
65
|
+
assignment to nested proxies. If `state.user` is undefined and the
|
|
66
|
+
consumer writes `state.user.profile.name = "Alice"`, this throws a
|
|
67
|
+
TypeError (cannot set property 'profile' of undefined).
|
|
68
|
+
|
|
69
|
+
To set deeply nested values that don't exist yet, use
|
|
70
|
+
`memorio.mutate("state.user.profile.name", "Alice")` which creates
|
|
71
|
+
intermediate objects programmatically.
|
|
72
|
+
|
|
73
|
+
### No-op mutations
|
|
74
|
+
|
|
75
|
+
If a set trap assigns the same value (`state.x = state.x`), the set
|
|
76
|
+
trap still fires. The callback still dispatches the event. However,
|
|
77
|
+
the `mutationToPatch` function returns `null` for `before === after`,
|
|
78
|
+
so no patch is stored in the commit log. The undo/redo stack records
|
|
79
|
+
the mutation (with `before === after`), but undo is a no-op for such
|
|
80
|
+
entries.
|
|
81
|
+
|
|
82
|
+
A future enhancement may add value-comparison in the set trap to skip
|
|
83
|
+
no-op dispatches. This ADR does **not** implement that.
|
|
84
|
+
|
|
85
|
+
### Delete semantics
|
|
86
|
+
|
|
87
|
+
`delete state.user.profile.name`:
|
|
88
|
+
1. The `deleteProperty` trap on the `profile` proxy fires.
|
|
89
|
+
2. `path` is computed as `user.profile.name`.
|
|
90
|
+
3. The callback receives `{ action: "delete", path, previousValue }`.
|
|
91
|
+
4. The event dispatched is `state.user.profile.name`.
|
|
92
|
+
5. The property is removed from the raw target.
|
|
93
|
+
|
|
94
|
+
### Array deep mutation
|
|
95
|
+
|
|
96
|
+
When a nested object inside an array is mutated (`state.items[0].name = "X"`):
|
|
97
|
+
1. The set trap fires on the proxy wrapping `items[0]`.
|
|
98
|
+
2. `path` is computed as `items.0.name` (array index joined by dots).
|
|
99
|
+
3. The event dispatched is `state.items.0.name`.
|
|
100
|
+
4. **Additionally**, because arrays use identity-based reconciliation,
|
|
101
|
+
the observer contract (ADR-002 §3) states that array observers fire
|
|
102
|
+
on the **array path** (`state.items`), not the element path. However,
|
|
103
|
+
the dispatch only fires `state.items.0.name`. Array observers
|
|
104
|
+
registered via `observer('state.items', ...)` will **not** fire for
|
|
105
|
+
`items[0].name` mutations.
|
|
106
|
+
|
|
107
|
+
> **Note:** This is a known limitation. Array element mutations that
|
|
108
|
+
> change object properties do not notify the array-path observer.
|
|
109
|
+
> A future scheduler or mutation engine enhancement may address this
|
|
110
|
+
> by dispatching on both `state.items.0.name` and `state.items`.
|
|
111
|
+
> This ADR documents the current behavior.
|
|
112
|
+
|
|
113
|
+
## Consequences
|
|
114
|
+
|
|
115
|
+
- **Positive:** Deep mutations are deterministic - exactly one event
|
|
116
|
+
per mutation, on the exact leaf path.
|
|
117
|
+
- **Positive:** Reference identity is preserved - the same proxy wrapper
|
|
118
|
+
is reused, preventing proxy-depth accumulation (regression tested).
|
|
119
|
+
- **Positive:** `memorio.mutate()` provides a safe path-based API for
|
|
120
|
+
setting deeply nested values that don't exist yet.
|
|
121
|
+
- **Negative:** Array element object mutations don't notify array-path
|
|
122
|
+
observers - consumers must observe the specific element path.
|
|
123
|
+
- **Negative:** No-op set traps still dispatch events - this is
|
|
124
|
+
intentional for consistency but may cause unnecessary re-renders.
|
|
125
|
+
|
|
126
|
+
## Compliance tests
|
|
127
|
+
|
|
128
|
+
- `tests/vitest/tests/contracts/adr-003-deep-mutation.test.ts`
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# ADR-004: Array Mutation Semantics
|
|
2
|
+
|
|
3
|
+
> **Status:** Proposed
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Arrays are the most error-prone data structure in proxy-based state
|
|
9
|
+
libraries. The 4.7.3 baseline handled `push` and `pop` but had no
|
|
10
|
+
explicit contract for `sort`, `splice`, `shift`, `unshift`, `fill`,
|
|
11
|
+
`reverse`, `length` changes, or whole-array replacement. Section 9 of
|
|
12
|
+
the Memorio 5.x plan demands explicit, regression-tested behavior for
|
|
13
|
+
every array operation.
|
|
14
|
+
|
|
15
|
+
A mutation that persists but does not notify, or notifies but does not
|
|
16
|
+
persist, is considered a **semantic defect** unless explicitly
|
|
17
|
+
documented.
|
|
18
|
+
|
|
19
|
+
## Assumptions
|
|
20
|
+
|
|
21
|
+
- Arrays are plain JS arrays stored on the Proxy raw target.
|
|
22
|
+
- Array methods (`push`, `pop`, `splice`, etc.) mutate the array in
|
|
23
|
+
place and trigger the set trap on the Proxy wrapping the array.
|
|
24
|
+
- The set trap's `key` for array mutations is the array index or
|
|
25
|
+
`length`.
|
|
26
|
+
- The callback receives `{ action, path, newValue, previousValue }`.
|
|
27
|
+
- The state Proxy dispatches via `dispatch.set("state." + path)`.
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
### Mutation → Persistence mapping
|
|
32
|
+
|
|
33
|
+
Every array operation below **must** persist to the underlying raw
|
|
34
|
+
array and **must** dispatch an event on the **array path**
|
|
35
|
+
(`state.<arrayPath>`), not on the individual index path. The event
|
|
36
|
+
path is computed from the Proxy `tree` (e.g. for `state.items`, the
|
|
37
|
+
tree is `["items"]`, so the path is `items` and the event is
|
|
38
|
+
`state.items`).
|
|
39
|
+
|
|
40
|
+
| Operation | Persists? | Dispatches on | Patch op |
|
|
41
|
+
|-----------|-----------|---------------|----------|
|
|
42
|
+
| `state.arr[i] = val` | Yes | `state.arr` | `replace` (index i) |
|
|
43
|
+
| `state.arr.push(val)` | Yes | `state.arr` | `add` (at index `arr.length`) |
|
|
44
|
+
| `state.arr.pop()` | Yes | `state.arr` | `remove` (last index) |
|
|
45
|
+
| `state.arr.shift()` | Yes | `state.arr` | `remove` (index 0) |
|
|
46
|
+
| `state.arr.unshift(val)` | Yes | `state.arr` | `add` (at index 0) |
|
|
47
|
+
| `state.arr.splice(i, n, ...items)` | Yes | `state.arr` | `remove` + `add` |
|
|
48
|
+
| `state.arr.sort()` | Yes | `state.arr` | `replace` (whole array) |
|
|
49
|
+
| `state.arr.reverse()` | Yes | `state.arr` | `replace` (whole array) |
|
|
50
|
+
| `state.arr.fill(val, start, end)` | Yes | `state.arr` | `replace` (whole array) |
|
|
51
|
+
| `state.arr.length = n` (truncate) | Yes | `state.arr` | `remove` (removed indices) |
|
|
52
|
+
| `state.arr.length = n` (extend) | Yes | `state.arr` | `add` (new indices) |
|
|
53
|
+
| `state.arr = newArray` | Yes | `state.arr` | `replace` (whole array) |
|
|
54
|
+
| `state.arr[i].prop = val` | Yes | `state.arr.i.prop` | `replace` (leaf) |
|
|
55
|
+
|
|
56
|
+
### sort() persistence
|
|
57
|
+
|
|
58
|
+
`sort()` mutates the array in place and returns a reference to the same
|
|
59
|
+
(sorted) array. The Proxy set trap fires because `length` changes and
|
|
60
|
+
indices are reassigned. The event dispatched is `state.arr`. The
|
|
61
|
+
mutation is **persisted** to the raw target.
|
|
62
|
+
|
|
63
|
+
**Known constraint:** `sort()` with a custom comparator must also
|
|
64
|
+
persist correctly. The comparator function is applied by V8's native
|
|
65
|
+
`Array.prototype.sort`; the Proxy only sees the resulting index
|
|
66
|
+
reassignments.
|
|
67
|
+
|
|
68
|
+
### splice() semantics
|
|
69
|
+
|
|
70
|
+
`splice()` can add, remove, or replace elements at an index. The Proxy
|
|
71
|
+
sees multiple set/delete traps. The event dispatched is `state.arr`
|
|
72
|
+
(the first trap fires, and subsequent traps on the same array path
|
|
73
|
+
are de-duplicated by the dispatch layer's microtask scheduling).
|
|
74
|
+
|
|
75
|
+
### length changes
|
|
76
|
+
|
|
77
|
+
Setting `arr.length = n` where `n < arr.length` truncates the array.
|
|
78
|
+
The deleteProperty trap fires for each removed index. Setting
|
|
79
|
+
`arr.length = n` where `n > arr.length` extends the array with
|
|
80
|
+
`undefined` holes; the set trap fires for `length`.
|
|
81
|
+
|
|
82
|
+
### Whole-array replacement
|
|
83
|
+
|
|
84
|
+
`state.arr = [1, 2, 3]` replaces the entire array. The set trap fires
|
|
85
|
+
once with `path = "arr"`, `newValue = [1, 2, 3]` (deepRaw'd). The
|
|
86
|
+
event dispatched is `state.arr`. Observers on `state.arr` fire.
|
|
87
|
+
Previous array observers (on the old array instance) are orphaned -
|
|
88
|
+
the consumer must re-register if they observe specific indices.
|
|
89
|
+
|
|
90
|
+
### Array element object mutation
|
|
91
|
+
|
|
92
|
+
`state.arr[0].name = "X"` mutates an object inside the array. This
|
|
93
|
+
fires the set trap on the Proxy wrapping `arr[0]` with
|
|
94
|
+
`path = "arr.0.name"`. The event dispatched is `state.arr.0.name`.
|
|
95
|
+
|
|
96
|
+
Per ADR-003, this does **not** notify observers on `state.arr`. This
|
|
97
|
+
is a known limitation. Consumers who need to observe changes to
|
|
98
|
+
objects within arrays must observe the specific element path
|
|
99
|
+
(`state.arr.0.name`) or use `useObserver` auto-discovery.
|
|
100
|
+
|
|
101
|
+
### Event deduplication
|
|
102
|
+
|
|
103
|
+
Because a single array operation (e.g. `splice`) can produce multiple
|
|
104
|
+
set/delete traps, the dispatch layer may fire `state.arr` multiple
|
|
105
|
+
times within the same synchronous block. Each dispatch schedules a
|
|
106
|
+
separate microtask. Observers on `state.arr` will fire **multiple
|
|
107
|
+
times** - once per trap. A future scheduler enhancement may coalesce
|
|
108
|
+
these. This ADR documents the current behavior.
|
|
109
|
+
|
|
110
|
+
## Consequences
|
|
111
|
+
|
|
112
|
+
- **Positive:** Every array operation persists and notifies - no
|
|
113
|
+
silent failures.
|
|
114
|
+
- **Positive:** Array observers fire on the array path, not individual
|
|
115
|
+
indices - this matches React list-reconciliation mental model.
|
|
116
|
+
- **Positive:** `sort()`, `splice()`, and other complex operations are
|
|
117
|
+
explicitly tested for persistence + notification.
|
|
118
|
+
- **Negative:** Multiple traps from a single array operation may
|
|
119
|
+
produce multiple observer notifications - consumers must
|
|
120
|
+
de-duplicate if needed.
|
|
121
|
+
- **Negative:** Array element object mutations do not notify the
|
|
122
|
+
array-path observer - this is a fundamental limitation of the
|
|
123
|
+
exact-path observer model.
|
|
124
|
+
|
|
125
|
+
## Compliance tests
|
|
126
|
+
|
|
127
|
+
- `tests/vitest/tests/contracts/adr-004-arrays.test.ts`
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# ADR-005: Scheduler Contract
|
|
2
|
+
|
|
3
|
+
> **Status:** Proposed
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The Memorio 5.x plan (Section 10) requires an explicit, configurable
|
|
9
|
+
scheduler. The 4.7.3 baseline dispatches observer callbacks via
|
|
10
|
+
`queueMicrotask` (in `core/dispatch.ts`). There is no configuration
|
|
11
|
+
point, no batching, and no flush control. This must be formalized into
|
|
12
|
+
a contract that supports future UI-framework integration (React
|
|
13
|
+
batched mode, requestAnimationFrame, manual flush for tests).
|
|
14
|
+
|
|
15
|
+
The scheduler contract must define: ordering, batching, reentrancy,
|
|
16
|
+
error isolation, nested mutations, transaction interaction, and flush
|
|
17
|
+
behavior.
|
|
18
|
+
|
|
19
|
+
## Assumptions
|
|
20
|
+
|
|
21
|
+
- The core engine dispatches events via `dispatch.set(name, value)`.
|
|
22
|
+
- `dispatch.listen(name, cb)` wraps `cb` in a microtask boundary.
|
|
23
|
+
- The scheduler is orthogonal to the Mutation Engine (ADR-007) and
|
|
24
|
+
Transactions (ADR-008) - it only affects *when* observer callbacks
|
|
25
|
+
fire, not *what* is recorded.
|
|
26
|
+
- `__DEV__` is a build-time constant.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
### Current default: microtask
|
|
31
|
+
|
|
32
|
+
The current dispatch layer uses `queueMicrotask` (or
|
|
33
|
+
`Promise.resolve().then(cb)` fallback). This means:
|
|
34
|
+
|
|
35
|
+
1. **Ordering:** Observer callbacks fire in registration order (FIFO),
|
|
36
|
+
within the microtask queue, after the current synchronous block
|
|
37
|
+
completes.
|
|
38
|
+
2. **Asynchronous:** Callbacks fire after the mutation is committed
|
|
39
|
+
to the raw Proxy target.
|
|
40
|
+
3. **No batching:** Each `dispatch.set` schedules its own microtask.
|
|
41
|
+
|
|
42
|
+
### Proposed scheduler configuration
|
|
43
|
+
|
|
44
|
+
A future `createMemorio({ scheduler: ... })` or
|
|
45
|
+
`memorio.configureScheduler({ mode, batching })` API would allow:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
type SchedulerMode = "sync" | "microtask" | "macrotask" | "raf" | "manual"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Mode | Behavior |
|
|
52
|
+
|------|----------|
|
|
53
|
+
| `sync` | Callbacks fire synchronously during `dispatch.set`. |
|
|
54
|
+
| `microtask` | Callbacks fire in the microtask queue (default). |
|
|
55
|
+
| `macrotask` | Callbacks fire via `setTimeout(cb, 0)` (macro task). |
|
|
56
|
+
| `raf` | Callbacks fire via `requestAnimationFrame`. |
|
|
57
|
+
| `manual` | Callbacks only fire when `flush()` is called. |
|
|
58
|
+
|
|
59
|
+
### Batching semantics (proposed)
|
|
60
|
+
|
|
61
|
+
When `batching: true` (only meaningful with `microtask` or `macrotask`
|
|
62
|
+
mode):
|
|
63
|
+
|
|
64
|
+
1. All `dispatch.set` calls within a single synchronous block are
|
|
65
|
+
collected.
|
|
66
|
+
2. Each unique event name is invoked once, after the synchronous
|
|
67
|
+
block completes, in the scheduled task.
|
|
68
|
+
3. The order of batched callbacks follows the order of first
|
|
69
|
+
`dispatch.set` for each unique event name.
|
|
70
|
+
|
|
71
|
+
When `batching: false` (default):
|
|
72
|
+
|
|
73
|
+
1. Each `dispatch.set` schedules its own callback independently.
|
|
74
|
+
|
|
75
|
+
### Reentrancy
|
|
76
|
+
|
|
77
|
+
- Reentrant `dispatch.set` calls (observer callbacks that dispatch new
|
|
78
|
+
events) are queued after the current callback completes.
|
|
79
|
+
- `sync` mode has no reentrancy boundary - recursive
|
|
80
|
+
`dispatch.set` calls fire immediately, risking stack overflow.
|
|
81
|
+
- No built-in guard against infinite dispatch loops. Consumers must
|
|
82
|
+
break the cycle.
|
|
83
|
+
|
|
84
|
+
### Error isolation
|
|
85
|
+
|
|
86
|
+
- A throwing callback in `microtask`/`macrotask`/`raf` mode produces
|
|
87
|
+
an unhandled rejection / error. Other callbacks scheduled in the same
|
|
88
|
+
tick are unaffected (each runs in its own closure).
|
|
89
|
+
- In `sync` mode, a throwing callback propagates to the caller of
|
|
90
|
+
`dispatch.set`.
|
|
91
|
+
- A future enhancement may wrap each callback in `try/catch` with an
|
|
92
|
+
optional error reporter. This ADR does **not** implement that.
|
|
93
|
+
|
|
94
|
+
### Nested mutations
|
|
95
|
+
|
|
96
|
+
Mutations inside observer callbacks create a new dispatch cycle. They
|
|
97
|
+
are **not** coalesced with the current cycle. This means:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
state.counter = 1
|
|
101
|
+
// microtask fires:
|
|
102
|
+
observer('state.counter', () => {
|
|
103
|
+
state.counter = 2 // → schedules a NEW microtask
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Transaction interaction
|
|
108
|
+
|
|
109
|
+
- During a transaction, each mutation dispatches normally (no
|
|
110
|
+
suppression).
|
|
111
|
+
- `undo()` / `redo()` set `internal.historyEnabled = false` to prevent
|
|
112
|
+
re-recording, but the Proxy set trap still dispatches events to
|
|
113
|
+
observers.
|
|
114
|
+
- A future "transaction-level notification" may suppress individual
|
|
115
|
+
mutation dispatches during a transaction and fire a single
|
|
116
|
+
"transaction-end" event. This ADR does **not** define that.
|
|
117
|
+
|
|
118
|
+
### Flush behavior
|
|
119
|
+
|
|
120
|
+
- In `manual` mode, `memorio.flush()` (or equivalent) processes all
|
|
121
|
+
queued callbacks. This is primarily for testing.
|
|
122
|
+
- In other modes, "flush" is implicit (microtask queue drains
|
|
123
|
+
naturally).
|
|
124
|
+
|
|
125
|
+
## Current status
|
|
126
|
+
|
|
127
|
+
The scheduler is currently **not configurable**. The default
|
|
128
|
+
`microtask` behavior is in effect. This ADR is **Proposed** - the
|
|
129
|
+
configuration API is a Phase 2/3 enhancement. The compliance tests
|
|
130
|
+
for the default behavior lock in the current semantics so that future
|
|
131
|
+
scheduler changes do not break existing code.
|
|
132
|
+
|
|
133
|
+
## Consequences
|
|
134
|
+
|
|
135
|
+
- **Positive:** Documenting the microtask default gives consumers a
|
|
136
|
+
deterministic mental model.
|
|
137
|
+
- **Positive:** The proposed API is framework-agnostic - all modes are
|
|
138
|
+
expressible as platform primitives.
|
|
139
|
+
- **Positive:** `manual` mode enables deterministic testing.
|
|
140
|
+
- **Negative:** No batching means multiple synchronous mutations each
|
|
141
|
+
produce a microtask - this is a known performance limitation for
|
|
142
|
+
bulk updates.
|
|
143
|
+
- **Negative:** No error isolation means a buggy observer can crash
|
|
144
|
+
the app via unhandled rejection.
|
|
145
|
+
|
|
146
|
+
## Compliance tests
|
|
147
|
+
|
|
148
|
+
- `tests/vitest/tests/contracts/adr-005-scheduler.test.ts`
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# ADR-006: Context Isolation Model
|
|
2
|
+
|
|
3
|
+
> **Status:** Accepted
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Memorio must support multiple isolated state trees within a single
|
|
9
|
+
process - for tests, micro-frontends, SSR, workers, and multi-tenant
|
|
10
|
+
environments. The 4.7.3 baseline uses `createContext` /
|
|
11
|
+
`memorio.isolate(name)` to create context-prefixed state, but the
|
|
12
|
+
mechanics and guarantees are undocumented.
|
|
13
|
+
|
|
14
|
+
This ADR defines how context isolation works, what the isolation
|
|
15
|
+
boundary is, and how it interacts with the Mutation Engine (ADR-007).
|
|
16
|
+
|
|
17
|
+
## Assumptions
|
|
18
|
+
|
|
19
|
+
- The core `state`, `store`, `session`, `cache` singletons live on
|
|
20
|
+
`globalThis` and are shared across all import paths (ESM, CJS,
|
|
21
|
+
absolute-path imports).
|
|
22
|
+
- A "context" is a string ID that prefixes internal store keys.
|
|
23
|
+
- `memorio.setContext(id)` sets the active context ID at runtime.
|
|
24
|
+
- Server-side code must create per-request contexts from trusted
|
|
25
|
+
server-side data (never client-controlled input).
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
### Global singleton vs. isolated context
|
|
30
|
+
|
|
31
|
+
- The global `state` / `store` / `session` / `cache` are **singletons**
|
|
32
|
+
on `globalThis`. Importing `memorio` in any module returns the same
|
|
33
|
+
instances. This is the default and is intended for single-instance
|
|
34
|
+
applications (e.g. one browser tab, one Next.js server instance).
|
|
35
|
+
- `memorio.isolate(name)` creates a new context and returns
|
|
36
|
+
context-scoped proxies. These proxies share the same underlying
|
|
37
|
+
engine but read/write to context-prefixed keys in `store`.
|
|
38
|
+
|
|
39
|
+
### Context ID
|
|
40
|
+
|
|
41
|
+
- Context IDs are strings. They must be unique within a process.
|
|
42
|
+
- Context IDs are **organizational**, not a security boundary. They
|
|
43
|
+
do not prevent unauthorized access to another context's data within
|
|
44
|
+
the same process. Server-side authorization must be enforced at the
|
|
45
|
+
application layer.
|
|
46
|
+
- `internal.currentContext` is a module-level string on the singleton
|
|
47
|
+
`internal` object. Mutations record the active context ID.
|
|
48
|
+
|
|
49
|
+
### Context-scoped state
|
|
50
|
+
|
|
51
|
+
When `internal.currentContext` is set to `ctxId`:
|
|
52
|
+
|
|
53
|
+
1. `state.set(key, value)` writes to `state` but records
|
|
54
|
+
`context: ctxId` in the MutationRecord.
|
|
55
|
+
2. `store.set(key, value)` writes to storage with key
|
|
56
|
+
`ctxId:key` (key-prefix isolation).
|
|
57
|
+
3. `session.set(key, value)` writes to storage with key
|
|
58
|
+
`ctxId:key`.
|
|
59
|
+
4. Reads from `store.get(key)` and `session.get(key)` read from
|
|
60
|
+
`ctxId:key`.
|
|
61
|
+
|
|
62
|
+
### Default context
|
|
63
|
+
|
|
64
|
+
When `internal.currentContext` is `null` (the default), all state and
|
|
65
|
+
store operations use the un-prefixed keys. This is the "default" or
|
|
66
|
+
"global" context.
|
|
67
|
+
|
|
68
|
+
### Multi-tenant caveat
|
|
69
|
+
|
|
70
|
+
`state` (the Proxy) is always global - it is a single reactive tree.
|
|
71
|
+
Context isolation applies to **persistence** (`store`, `session`) and
|
|
72
|
+
**mutation records** (`context` field). To achieve true state-tree
|
|
73
|
+
isolation, consumers should use `memorio.isolate(name)` which creates
|
|
74
|
+
a new context, or instantiate isolated Memorio instances.
|
|
75
|
+
|
|
76
|
+
## Consequences
|
|
77
|
+
|
|
78
|
+
- **Positive:** Single global singleton simplifies the common case
|
|
79
|
+
(one app, one state tree).
|
|
80
|
+
- **Positive:** Context-prefixed persistence enables multi-tenant
|
|
81
|
+
server-side usage without separate state trees.
|
|
82
|
+
- **Positive:** Mutation records carry context, enabling causal-graph
|
|
83
|
+
partitioning (Phase 4).
|
|
84
|
+
- **Negative:** `state` itself is not multi-tenant - consumers needing
|
|
85
|
+
isolated state trees must use `isolate()` or separate processes.
|
|
86
|
+
- **Negative:** Context isolation is organizational, not a security
|
|
87
|
+
boundary - documented clearly in AGENTS.md.
|
|
88
|
+
|
|
89
|
+
## Compliance tests
|
|
90
|
+
|
|
91
|
+
- `tests/vitest/tests/contracts/adr-006-context.test.ts`
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# ADR-007: Mutation Records
|
|
2
|
+
|
|
3
|
+
> **Status:** Accepted
|
|
4
|
+
> **Date:** 2026-09-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Section 6 of the Memorio 5.x plan requires every state mutation to pass
|
|
9
|
+
through a normalized internal `MutationRecord`. The 4.7.3 baseline has
|
|
10
|
+
an informal `MutationRecord` interface in `core/internal.ts` with
|
|
11
|
+
`path`, `action`, `newValue`, `previousValue`, `timestamp`. The 5.0
|
|
12
|
+
codebase has extended this with `id`, `operation`, `hlc`, `context`,
|
|
13
|
+
`source`, and `transactionId` (see `core/mutation/types.ts`).
|
|
14
|
+
|
|
15
|
+
This ADR formalizes the canonical record structure and guarantees.
|
|
16
|
+
|
|
17
|
+
## Assumptions
|
|
18
|
+
|
|
19
|
+
- Every `set`/`delete` trap on the state Proxy calls the
|
|
20
|
+
`buildProxy` callback once.
|
|
21
|
+
- The callback constructs a `Mutation` via `createMutation()` which:
|
|
22
|
+
- Generates an HLC-based ID (`m_<encoded>`).
|
|
23
|
+
- Captures `before` = previous value, `after` = new value.
|
|
24
|
+
- Tags the mutation with the active transaction ID (if any).
|
|
25
|
+
- Sets `context` from `internal.currentContext`.
|
|
26
|
+
- `recordMutation()` is the single write point to the trace log and
|
|
27
|
+
undo/redo stacks (in `core/internal.ts`).
|
|
28
|
+
- History tracking is opt-in via `enableHistory(true)`.
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
### Canonical MutationRecord structure
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
interface MutationRecord {
|
|
36
|
+
// --- Canonical Memorio 5 fields ---
|
|
37
|
+
id: string // HLC-based UUID: "m_<encoded-hlc>"
|
|
38
|
+
path: string // Dotted path relative to state: "user.profile.name"
|
|
39
|
+
operation: 'set' | 'delete' | 'insert' | 'remove' | 'replace' | 'length'
|
|
40
|
+
before: any // Value before mutation (deepRaw'd)
|
|
41
|
+
after: any // Value after mutation (deepRaw'd)
|
|
42
|
+
timestamp: number // Epoch ms
|
|
43
|
+
hlc: string // Serialized HLC: "hlc:<physical>:<logical>:<node>"
|
|
44
|
+
|
|
45
|
+
// --- Metadata ---
|
|
46
|
+
context?: string // Active context ID
|
|
47
|
+
source?: string // Optional attribution (e.g. "profile.save")
|
|
48
|
+
transactionId?: string // Active transaction ID, if any
|
|
49
|
+
parentId?: string // Parent mutation ID (for causal graph)
|
|
50
|
+
|
|
51
|
+
// --- Legacy fields (backward compatibility) ---
|
|
52
|
+
action: 'set' | 'delete' // Mirrors operation (set/delete only)
|
|
53
|
+
newValue: any // Alias for `after`
|
|
54
|
+
previousValue: any // Alias for `before`
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Guarantees
|
|
59
|
+
|
|
60
|
+
1. **Every mutation gets a unique ID.** The ID is generated via
|
|
61
|
+
`generateMutationId()` which calls `hlc.tick()` - guaranteeing
|
|
62
|
+
causal ordering within a single node. The node suffix ensures
|
|
63
|
+
uniqueness across devices.
|
|
64
|
+
|
|
65
|
+
2. **Before/after are deepRaw'd.** Proxy wrappers are never stored in
|
|
66
|
+
the record - only plain values. This ensures serializable,
|
|
67
|
+
comparison-safe records.
|
|
68
|
+
|
|
69
|
+
3. **Single write point.** `internal.recordMutation()` is the only
|
|
70
|
+
function that pushes to `mutations`, `undoStack`, or `redoStack`.
|
|
71
|
+
No other code path mutates these arrays directly.
|
|
72
|
+
|
|
73
|
+
4. **Redo stack clears on new mutation.** Any mutation (including
|
|
74
|
+
undo/redo inverse writes, since those temporarily disable history)
|
|
75
|
+
must NOT clear the redo stack - undo/redo use
|
|
76
|
+
`internal.historyEnabled = false` to bypass `recordMutation`.
|
|
77
|
+
|
|
78
|
+
5. **Max history depth.** Both stacks are trimmed to
|
|
79
|
+
`internal.maxHistory` (default: 100). Trimming removes from the
|
|
80
|
+
front (shift), preserving recency.
|
|
81
|
+
|
|
82
|
+
6. **No-op mutations.** If `before === after`, the record is still
|
|
83
|
+
created and stored (the mutation happened, even if the value
|
|
84
|
+
didn't change). `mutationToPatch()` returns `null` for no-ops, so
|
|
85
|
+
no patch is stored in a commit's patch list.
|
|
86
|
+
|
|
87
|
+
### Reentrancy guard
|
|
88
|
+
|
|
89
|
+
The `mutate()` function sets `internal._recording = false` before
|
|
90
|
+
writing to the state Proxy, then calls `recordMutation()` itself. This
|
|
91
|
+
prevents the Proxy callback from double-recording the same mutation.
|
|
92
|
+
|
|
93
|
+
### Transaction tagging
|
|
94
|
+
|
|
95
|
+
During an active transaction, `createMutation()` calls
|
|
96
|
+
`tagForTransaction()` to attach the current transaction ID.
|
|
97
|
+
`registerMutationInTransaction(id)` adds the mutation ID to the
|
|
98
|
+
transaction's mutation list.
|
|
99
|
+
|
|
100
|
+
## Consequences
|
|
101
|
+
|
|
102
|
+
- **Positive:** A single, well-typed record structure serves undo,
|
|
103
|
+
redo, trace, diff, and future replay/revert.
|
|
104
|
+
- **Positive:** HLC-based IDs provide causal ordering for the causal
|
|
105
|
+
graph and simulation engine.
|
|
106
|
+
- **Positive:** Legacy fields maintain full backward compatibility
|
|
107
|
+
with 4.x consumers.
|
|
108
|
+
- **Negative:** Storing both legacy and canonical fields doubles the
|
|
109
|
+
record size - acceptable for the opt-in history feature.
|
|
110
|
+
- **Negative:** No-op mutations are stored - a future optimization
|
|
111
|
+
could skip them in the set trap, but the Mutation Engine must remain
|
|
112
|
+
the source of truth.
|
|
113
|
+
|
|
114
|
+
## Compliance tests
|
|
115
|
+
|
|
116
|
+
- `tests/vitest/tests/contracts/adr-007-mutation-records.test.ts`
|
|
117
|
+
- `tests/vitest/tests/mutation-engine.test.ts` (existing)
|