@table-games-engine/persistence 0.1.0 → 0.1.1
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/README.md +51 -146
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,164 +1,69 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
Persistence
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
taken per policy).
|
|
24
|
-
- **Snapshot policies** — `everyNTransitions`, `everyTTicks`, `always`,
|
|
25
|
-
`never`, `combine`.
|
|
26
|
-
- **`replayMatch` / `loadEventsBetween`** — reconstruction helpers.
|
|
1
|
+
# @table-games-engine/persistence
|
|
2
|
+
|
|
3
|
+
Persistence for table-games-engine matches: event and snapshot storage (in-memory or SQLite), snapshot policies, state checksums, replay, and ownership leases.
|
|
4
|
+
|
|
5
|
+
Most game authors don't install this directly: it comes with [`@table-games-engine/game-kit`](https://www.npmjs.com/package/@table-games-engine/game-kit) and is used by [`@table-games-engine/nakama-adapter`](https://www.npmjs.com/package/@table-games-engine/nakama-adapter). Install it to keep matches alive across restarts on your own host.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @table-games-engine/persistence
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`SqliteAdapter` uses `better-sqlite3` (a native module, installed as a dependency).
|
|
14
|
+
|
|
15
|
+
## What is in it
|
|
16
|
+
|
|
17
|
+
- `InMemoryAdapter` - no I/O, for tests and ephemeral runs.
|
|
18
|
+
- `SqliteAdapter(path, opts?)` - SQLite via `better-sqlite3`; takes a file path or `':memory:'`; WAL mode, migrations applied on open.
|
|
19
|
+
- `createPersistedEngine(args)` - wraps a core `Engine` so every transition appends events and snapshots per policy.
|
|
20
|
+
- `checksumState(state)` - deterministic checksum of a `MatchState` (FNV-1a 32-bit over canonical JSON).
|
|
21
|
+
- `replayMatch(adapter, matchId, toVersion?)` and `loadEventsBetween(adapter, matchId, from, to)`.
|
|
22
|
+
- Snapshot policies: `everyNTransitions`, `everyTTicks`, `always`, `never`, `combine`.
|
|
27
23
|
|
|
28
24
|
## Quick start
|
|
29
25
|
|
|
30
26
|
```ts
|
|
31
|
-
import {
|
|
32
|
-
SqliteAdapter,
|
|
33
|
-
createPersistedEngine,
|
|
34
|
-
everyNTransitions,
|
|
35
|
-
replayMatch,
|
|
36
|
-
checksumState,
|
|
37
|
-
} from '@table-games-engine/persistence';
|
|
38
|
-
import { manifest, handlers, initialState } from './my-game';
|
|
27
|
+
import { SqliteAdapter, createPersistedEngine, everyNTransitions, replayMatch, checksumState } from '@table-games-engine/persistence';
|
|
39
28
|
|
|
40
29
|
const adapter = new SqliteAdapter('./match.db');
|
|
41
30
|
|
|
42
31
|
const engine = await createPersistedEngine({
|
|
43
|
-
manifest,
|
|
44
|
-
initialState,
|
|
45
|
-
handlers,
|
|
32
|
+
manifest, initialState, handlers,
|
|
46
33
|
adapter,
|
|
47
34
|
matchId: 'match-42',
|
|
48
|
-
policy: everyNTransitions(50),
|
|
49
|
-
ownerNode: 'node-A',
|
|
50
|
-
leaseSeconds: 60,
|
|
35
|
+
policy: everyNTransitions(50), // default
|
|
36
|
+
ownerNode: 'node-A', // optional ownership lease
|
|
37
|
+
leaseSeconds: 60, // optional, default 60
|
|
51
38
|
});
|
|
52
39
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (r.ok) {
|
|
56
|
-
await engine.resolveTopFrame();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Optional: force a snapshot at the current state (e.g. on match end).
|
|
60
|
-
await engine.forceSnapshot();
|
|
40
|
+
const r = await engine.applyAction(myInput); // same shape as the core Engine, but async
|
|
41
|
+
if (r.ok) await engine.resolveTopFrame();
|
|
61
42
|
|
|
62
|
-
//
|
|
43
|
+
await engine.forceSnapshot(); // e.g. on match end
|
|
63
44
|
const recovered = await replayMatch(adapter, 'match-42');
|
|
64
|
-
|
|
65
|
-
// Verify integrity (the adapter does this on load, but you may want
|
|
66
|
-
// to assert it matches the live engine before swapping).
|
|
67
45
|
checksumState(recovered!) === checksumState(engine.getState());
|
|
68
|
-
|
|
69
|
-
// Release the lease and close the DB handle.
|
|
70
|
-
await engine.close();
|
|
46
|
+
await engine.close(); // releases the lease and closes the DB handle
|
|
71
47
|
```
|
|
72
48
|
|
|
73
|
-
## Snapshot
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
windows, but more bytes written per second and higher checksum cost.
|
|
93
|
-
- **Sparse snapshots** (high N): cheaper writes, but larger windows
|
|
94
|
-
between recoverable points. With M2's design, you can only recover
|
|
95
|
-
to versions that were snapshotted (see "Replay contract" below).
|
|
96
|
-
- **`always()`** is the right default for **async-mode** games (every
|
|
97
|
-
action persists; players may be offline for hours/days) and for
|
|
98
|
-
tests that want deterministic post-conditions.
|
|
99
|
-
- **`combine(everyNTransitions(50), everyTTicks(60))`** mirrors the
|
|
100
|
-
M2 plan's "every N transitions OR every T seconds" hybrid.
|
|
101
|
-
|
|
102
|
-
## Replay contract (M2 design)
|
|
103
|
-
|
|
104
|
-
`replayMatch(adapter, matchId, toVersion?)` returns the **latest
|
|
105
|
-
snapshot whose `state.version` is `<= toVersion`**, or the absolute
|
|
106
|
-
latest snapshot when `toVersion` is omitted, or `null` when nothing has
|
|
107
|
-
been persisted.
|
|
108
|
-
|
|
109
|
-
It does **not** replay events onto state. This is a deliberate M2
|
|
110
|
-
decision:
|
|
111
|
-
|
|
112
|
-
- The adapter stores **snapshots** (full state) and **events**
|
|
113
|
-
(game-domain events for triggers, audit, spectator catch-up).
|
|
114
|
-
- Events are NOT a derivation of `StateMutation`s. We cannot
|
|
115
|
-
reconstruct state at an arbitrary version V by "applying events" —
|
|
116
|
-
there is no such operation defined.
|
|
117
|
-
- To recover to an exact version V, configure a snapshot policy that
|
|
118
|
-
guarantees a snapshot at V (e.g. `always()` or a turn-aligned
|
|
119
|
-
cadence).
|
|
120
|
-
- For audit, use `loadEventsBetween(adapter, matchId, from, to)` to
|
|
121
|
-
fetch the half-open `(from, to]` slice of the event log.
|
|
122
|
-
|
|
123
|
-
M3 (Nakama adapter) will revisit this if cross-node failover requires
|
|
124
|
-
sub-snapshot precision. For now, snapshot density is the knob.
|
|
125
|
-
|
|
126
|
-
## Sync/async boundary
|
|
127
|
-
|
|
128
|
-
The core `Engine` API is **synchronous**: `applyAction(input): Result<...>`.
|
|
129
|
-
The persisted wrapper exposes the same shape but each transition method
|
|
130
|
-
returns a `Promise<Result<...>>` because adapter writes are async.
|
|
131
|
-
|
|
132
|
-
Adapter failures (e.g. SQLite I/O error, network error in a future
|
|
133
|
-
remote adapter) **propagate to the caller as a rejected Promise**.
|
|
134
|
-
They do **not** roll back the in-memory state — by the time the adapter
|
|
135
|
-
write happens, the engine has already produced and applied the
|
|
136
|
-
transition. Callers that need all-or-nothing semantics must layer their
|
|
137
|
-
own retry / quarantine on top, or close the engine and recover from the
|
|
138
|
-
last good snapshot.
|
|
139
|
-
|
|
140
|
-
## Ownership
|
|
141
|
-
|
|
142
|
-
If you pass `ownerNode` to `createPersistedEngine`, the wrapper
|
|
143
|
-
acquires an exclusive lease on `matchId` before doing anything else.
|
|
144
|
-
Construction throws if another node holds a non-expired lease.
|
|
145
|
-
|
|
146
|
-
Leases auto-expire after `leaseSeconds` seconds. Use `engine.close()`
|
|
147
|
-
or `engine.releaseOwnership()` to release explicitly.
|
|
148
|
-
|
|
149
|
-
## Determinism guarantees
|
|
150
|
-
|
|
151
|
-
- The wrapper's behaviour matches the unwrapped `createEngine` exactly
|
|
152
|
-
for game logic. Tests assert byte-for-byte state equality.
|
|
153
|
-
- Checksums are deterministic across machines and processes.
|
|
154
|
-
- `SqliteAdapter` uses WAL mode and per-call transactions for atomic
|
|
155
|
-
writes.
|
|
156
|
-
|
|
157
|
-
## Testing
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
npm test --workspace=@table-games-engine/persistence
|
|
161
|
-
```
|
|
49
|
+
## Snapshot policies
|
|
50
|
+
|
|
51
|
+
| Policy | Fires |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `everyNTransitions(n)` | after every `n` transitions |
|
|
54
|
+
| `everyTTicks(t)` | after every `t` logical ticks |
|
|
55
|
+
| `always()` | every transition (best for async games and deterministic tests) |
|
|
56
|
+
| `never()` | never |
|
|
57
|
+
| `combine(...policies)` | when any sub-policy fires |
|
|
58
|
+
|
|
59
|
+
## Replay
|
|
60
|
+
|
|
61
|
+
`replayMatch` returns the latest snapshot at or before `toVersion` (or the latest overall), or `null` if nothing was stored. It does not re-apply events, so to recover an exact version use a policy that snapshots there (for example `always()`). Use `loadEventsBetween` for the audit log.
|
|
62
|
+
|
|
63
|
+
## Failure behaviour
|
|
64
|
+
|
|
65
|
+
Adapter errors reject the returned promise and do not roll back in-memory state. If you need all-or-nothing, close the engine and recover from the last good snapshot. With `ownerNode` set, construction throws if another node holds an unexpired lease.
|
|
66
|
+
|
|
67
|
+
## Licence
|
|
162
68
|
|
|
163
|
-
|
|
164
|
-
tests use `SqliteAdapter(':memory:')` for hermetic SQLite coverage.
|
|
69
|
+
FSL-1.1-ALv2 (Functional Source License, converts to Apache-2.0 after two years). See `LICENSE.md` in the package.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@table-games-engine/persistence",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"test:watch": "vitest"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@table-games-engine/core": "^0.1.
|
|
29
|
+
"@table-games-engine/core": "^0.1.1",
|
|
30
30
|
"better-sqlite3": "^11.0.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
@@ -34,5 +34,15 @@
|
|
|
34
34
|
"typescript": "^5.4.0",
|
|
35
35
|
"vitest": "^2.0.0"
|
|
36
36
|
},
|
|
37
|
-
"license": "FSL-1.1-ALv2"
|
|
37
|
+
"license": "FSL-1.1-ALv2",
|
|
38
|
+
"description": "Snapshot, event and replay persistence (in-memory and SQLite) for table-games-engine matches.",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"table-games",
|
|
41
|
+
"turn-based",
|
|
42
|
+
"multiplayer",
|
|
43
|
+
"game-engine",
|
|
44
|
+
"nakama",
|
|
45
|
+
"boardgame"
|
|
46
|
+
],
|
|
47
|
+
"homepage": "https://www.npmjs.com/package/@table-games-engine/persistence"
|
|
38
48
|
}
|