@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.
Files changed (2) hide show
  1. package/README.md +51 -146
  2. package/package.json +13 -3
package/README.md CHANGED
@@ -1,164 +1,69 @@
1
- # `@table-games-engine/persistence`
2
-
3
- Persistence layer for the table-games engine. Implements the
4
- `EngineAdapter` contract from `@table-games-engine/core` and provides
5
- helpers for snapshotting, replay, and ownership leasing.
6
-
7
- This package is internal to the workspace (`"private": true`) and is
8
- imported by the runtime, the Nakama adapter (M3), and any host that
9
- needs to keep matches alive across restarts.
10
-
11
- ## What lives here
12
-
13
- - **`InMemoryAdapter`** — reference adapter, no I/O. Used in tests and
14
- for ephemeral sandbox runs.
15
- - **`SqliteAdapter`** — SQLite-backed adapter via `better-sqlite3`.
16
- Construct with a filesystem path or `':memory:'`. Migrations are
17
- applied eagerly.
18
- - **`checksumState`** — deterministic JSON-canonical FNV-1a 32-bit
19
- checksum of a `MatchState`. Used by both adapters and by callers
20
- that want to verify replayed state.
21
- - **`createPersistedEngine`** — wraps an in-process `Engine` so every
22
- transition is mirrored into the adapter (events appended, snapshots
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), // default, shown for clarity
49
- ownerNode: 'node-A', // optional ownership lease
50
- leaseSeconds: 60, // optional, defaults to 60
35
+ policy: everyNTransitions(50), // default
36
+ ownerNode: 'node-A', // optional ownership lease
37
+ leaseSeconds: 60, // optional, default 60
51
38
  });
52
39
 
53
- // Drive the game. Each call returns a Promise<Result<...>>.
54
- const r = await engine.applyAction(myInput);
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
- // Recover from anywhere: latest snapshot at or before a given version.
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 cadence
74
-
75
- The persisted engine is policy-driven; pass `policy` when you construct
76
- it. Built-in policies:
77
-
78
- | Policy | When it fires |
79
- | -------------------------- | -------------------------------------- |
80
- | `everyNTransitions(n)` | After every `n` engine transitions |
81
- | `everyTTicks(t)` | After every `t` logical ticks |
82
- | `always()` | Every transition (deterministic tests) |
83
- | `never()` | Never (benchmarks, audit-only engines) |
84
- | `combine(...policies)` | If any sub-policy returns true |
85
-
86
- Default is `everyNTransitions(50)`, matching the M2 plan's "snapshot
87
- every ~50 transitions" guidance.
88
-
89
- ### Cadence trade-offs
90
-
91
- - **Frequent snapshots** (low N): faster recovery, smaller replay
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
- Unit tests use `InMemoryAdapter` (no I/O); the round-trip integration
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.0",
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.0",
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
  }