@table-games-engine/persistence 0.0.0-stage → 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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @fileoverview `PersistedEngine` — `Engine` + adapter writes.
3
+ *
4
+ * Wraps an in-process `Engine` (from `@table-games-engine/core`) so that every
5
+ * transition-producing call ALSO:
6
+ * 1. Appends the transition's events to the adapter's event log
7
+ * (audit trail / triggers / spectator catch-up).
8
+ * 2. Asks the configured `SnapshotPolicy` whether to snapshot, and if
9
+ * so writes the new `MatchState` (with checksum) to the adapter.
10
+ *
11
+ * # Sync vs async boundary
12
+ *
13
+ * The core `Engine` API is **synchronous**: `applyAction(input): Result<...>`
14
+ * returns immediately. The persisted wrapper exposes the same shape but
15
+ * each method returns a `Promise<Result<...>>` because adapter writes are
16
+ * async. Internally:
17
+ * 1. The underlying engine call runs synchronously and updates the
18
+ * in-memory state. (This is the source of truth — the wrapper does
19
+ * NOT undo it on adapter failure.)
20
+ * 2. The adapter writes are awaited.
21
+ * 3. Adapter errors propagate to the caller via the returned Promise.
22
+ * They do NOT roll back the in-memory state. Document this clearly:
23
+ * callers that require all-or-nothing semantics must layer their own
24
+ * retry/quarantine logic above the wrapper, or close the engine and
25
+ * restart from the last good snapshot.
26
+ *
27
+ * The trade-off is intentional. The alternative (rollback the in-memory
28
+ * state on failure) would require synthesising an inverse transition or
29
+ * snapshotting before each call, both of which are noticeably more
30
+ * expensive than letting the host runtime decide what to do on failure.
31
+ *
32
+ * # Ownership
33
+ *
34
+ * If `ownerNode` is provided to {@link createPersistedEngine}, the wrapper
35
+ * acquires an ownership lease on `matchId` before doing anything else.
36
+ * On failure (another node holds a non-expired lease) the factory throws.
37
+ * `release()` releases the lease; `close()` releases AND closes the
38
+ * adapter handle.
39
+ *
40
+ * # Initial snapshot
41
+ *
42
+ * The wrapper unconditionally persists the initial state as snapshot v0.
43
+ * This guarantees `replayMatch` returns a valid state even if zero
44
+ * transitions occur before a crash, and gives the snapshot policy a
45
+ * concrete "since-last" baseline to count from.
46
+ */
47
+ import type { ActionHandler, EngineAdapter, GameEndCheck, GameManifest, MatchId, MatchState, ResolutionFailure, Result, StateTransition } from '@table-games-engine/core';
48
+ import type { ActionInput, ActionValidationResult } from '@table-games-engine/core';
49
+ import { type SnapshotPolicy } from './snapshots.js';
50
+ /**
51
+ * Arguments for {@link createPersistedEngine}.
52
+ */
53
+ export interface CreatePersistedEngineArgs {
54
+ /** Game manifest passed straight through to {@link createEngine}. */
55
+ readonly manifest: GameManifest;
56
+ /** Initial state (will be persisted as snapshot v0). */
57
+ readonly initialState: MatchState;
58
+ /** Action handlers passed straight through to {@link createEngine}. */
59
+ readonly handlers: Record<string, ActionHandler>;
60
+ /** Persistence backend. The wrapper does NOT take ownership of close(). */
61
+ readonly adapter: EngineAdapter;
62
+ /** Match ID used for snapshot/event/ownership scoping. */
63
+ readonly matchId: MatchId;
64
+ /** Snapshot cadence; defaults to `everyNTransitions(50)` per the M2 plan. */
65
+ readonly policy?: SnapshotPolicy;
66
+ /**
67
+ * If provided, the wrapper acquires an ownership lease on `matchId` for
68
+ * `ownerNode` during construction. Construction throws if the lease
69
+ * cannot be acquired (another node owns it).
70
+ */
71
+ readonly ownerNode?: string;
72
+ /** Lease duration in seconds. Defaults to 60. */
73
+ readonly leaseSeconds?: number;
74
+ }
75
+ /**
76
+ * Persisted engine handle.
77
+ *
78
+ * Mirrors the shape of `Engine` but every transition method is async (because
79
+ * adapter writes are async) and returns a `Promise<Result<...>>`. Adds
80
+ * lifecycle methods for ownership and snapshot control.
81
+ */
82
+ export interface PersistedEngine {
83
+ /** Synchronous read of the current `MatchState`. */
84
+ getState(): MatchState;
85
+ /** Match ID this wrapper is bound to. */
86
+ getMatchId(): MatchId;
87
+ /**
88
+ * Apply a player action and persist the resulting transition.
89
+ *
90
+ * On success: in-memory state is updated, events are appended, and a
91
+ * snapshot is taken if the policy fires.
92
+ *
93
+ * On engine validation failure: no adapter writes occur — the engine
94
+ * never produced a transition.
95
+ *
96
+ * On adapter failure: the in-memory state retains the new value (the
97
+ * engine has already applied it). The promise rejects with the adapter
98
+ * error. See module doc for rationale.
99
+ */
100
+ applyAction(input: ActionInput): Promise<Result<StateTransition, ActionValidationResult>>;
101
+ resolveTopFrame(): Promise<Result<StateTransition, ResolutionFailure>>;
102
+ processTriggers(): Promise<Result<StateTransition, never>>;
103
+ checkStateBasedActions(): Promise<Result<StateTransition, never>>;
104
+ advancePhase(): Promise<Result<StateTransition, 'CANNOT_ADVANCE'>>;
105
+ advanceTurn(): Promise<Result<StateTransition, 'CANNOT_ADVANCE'>>;
106
+ /** Synchronous (the engine helper is sync and produces no transition). */
107
+ checkWinConditions(): GameEndCheck;
108
+ /**
109
+ * Force a snapshot at the current state, regardless of the policy.
110
+ *
111
+ * Useful for "checkpoint before risky operation", "snapshot on match
112
+ * end", or test determinism.
113
+ */
114
+ forceSnapshot(): Promise<void>;
115
+ /** Release the ownership lease (if any). Idempotent. */
116
+ releaseOwnership(): Promise<void>;
117
+ /**
118
+ * Release ownership AND close the underlying adapter. Idempotent for
119
+ * ownership; the adapter close behaviour is whatever the adapter
120
+ * documents (better-sqlite3 tolerates double-close).
121
+ */
122
+ close(): Promise<void>;
123
+ }
124
+ /**
125
+ * Create a persisted engine wrapper.
126
+ *
127
+ * Steps performed during construction (in order):
128
+ * 1. Acquire ownership lease (if `ownerNode` provided). Throws on failure.
129
+ * 2. Persist `initialState` as the v0 snapshot.
130
+ * 3. Initialise the in-memory `Engine` and return the wrapper.
131
+ *
132
+ * The default policy is `everyNTransitions(50)` per the M2 plan. Tests that
133
+ * want deterministic snapshot frequency should pass `always()` or `never()`.
134
+ */
135
+ export declare function createPersistedEngine(args: CreatePersistedEngineArgs): Promise<PersistedEngine>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @fileoverview Replay helper for `EngineAdapter`.
3
+ *
4
+ * # Replay contract (M2 design)
5
+ *
6
+ * The adapter stores TWO independent streams per match:
7
+ * - **Snapshots** — full `MatchState` blobs at specific versions.
8
+ * - **Events** — `GameEvent` records for triggers / audit / spectator
9
+ * catch-up.
10
+ *
11
+ * Crucially, events are NOT a derivation of `StateMutation`s — they are
12
+ * the game-domain things that happened (e.g. `damage-dealt`, `card-drawn`).
13
+ * That means we cannot reconstruct state by "replaying events" the way
14
+ * an event-sourced system might. To rebuild state at version V we have
15
+ * two options:
16
+ *
17
+ * 1. Load a snapshot whose version is `<= V`, and either accept its
18
+ * version (if there is no snapshot at exactly V) or re-execute the
19
+ * original action handlers from that snapshot up to V.
20
+ * 2. Persist a snapshot at every version (snapshot policy
21
+ * `always()`), which collapses (1) to "load the snapshot at V".
22
+ *
23
+ * In M2 we deliberately choose the simpler design: `replayMatch` returns
24
+ * the **latest snapshot at or before `toVersion`**. Callers control
25
+ * snapshot cadence via `SnapshotPolicy` (see `./snapshots.ts`); if a
26
+ * caller needs to recover to an exact version V, they should configure
27
+ * a policy that snapshots at V (e.g. `always()` or a turn-aligned
28
+ * cadence).
29
+ *
30
+ * Re-executing handlers between two snapshots to land on an arbitrary
31
+ * intermediate version is intentionally out of scope for M2. M3
32
+ * (Nakama adapter) will revisit this if cross-node failover requires
33
+ * sub-snapshot precision; for now, snapshot density is the knob.
34
+ *
35
+ * `loadEventsBetween` is a thin sugar wrapper around
36
+ * `EngineAdapter.loadEvents` for callers that want the audit trail
37
+ * across a version range.
38
+ */
39
+ import type { EngineAdapter, MatchId, MatchState, StateVersion, GameEvent, ImmutableList } from '@table-games-engine/core';
40
+ /**
41
+ * Reconstruct the recorded `MatchState` for `matchId` at or before
42
+ * `toVersion`.
43
+ *
44
+ * Returns the latest snapshot whose `state.version` is `<= toVersion`,
45
+ * or the absolute latest snapshot when `toVersion` is omitted, or
46
+ * `null` when no snapshot has been persisted for the match.
47
+ *
48
+ * The returned state is the exact value that was passed to
49
+ * `persistSnapshot` at the time of capture (modulo JSON round-trip,
50
+ * which `MatchState` is designed to survive).
51
+ *
52
+ * Pure with respect to the caller: makes no engine calls, only adapter
53
+ * reads. The integrity check (checksum verification) happens inside
54
+ * `loadSnapshot` per the adapter contract.
55
+ */
56
+ export declare function replayMatch(adapter: EngineAdapter, matchId: MatchId, toVersion?: StateVersion): Promise<MatchState | null>;
57
+ /**
58
+ * Load events recorded in the half-open interval `(fromVersion, toVersion]`.
59
+ *
60
+ * Convenience alias for `EngineAdapter.loadEvents`; exposed from the
61
+ * replay module so callers that import "replay-related" helpers find it
62
+ * alongside `replayMatch`.
63
+ */
64
+ export declare function loadEventsBetween(adapter: EngineAdapter, matchId: MatchId, fromVersion: StateVersion, toVersion: StateVersion): Promise<ImmutableList<GameEvent>>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @fileoverview Snapshot cadence policies for the persisted engine.
3
+ *
4
+ * A `SnapshotPolicy` decides — given the current `MatchState` and a count of
5
+ * "things since the last snapshot" — whether the engine should snapshot
6
+ * RIGHT NOW. Policies are stateless predicates; the persisted engine is
7
+ * responsible for tracking `transitionsSince` and `ticksSince`.
8
+ *
9
+ * Policies are pluggable and composable: real deployments will combine a
10
+ * "every N transitions" rule with a "every T seconds of logical tick" rule
11
+ * via {@link combine}, while tests use {@link always} (snapshot every step)
12
+ * or {@link never} (snapshot never).
13
+ *
14
+ * # Why a separate "since-last" counter?
15
+ *
16
+ * `MatchState.version` could be inspected directly, but the wrapper does
17
+ * NOT have access to the version of the previous snapshot in a stateless
18
+ * predicate. We keep the policy pure and let the wrapper own the counters.
19
+ * This keeps the policy a single-line decision and makes `combine` trivial.
20
+ *
21
+ * # Defaults
22
+ *
23
+ * The persisted engine wrapper defaults to `everyNTransitions(50)` when
24
+ * the caller does not supply a policy — chosen to align with the M2 plan
25
+ * "snapshot every ~50 transitions" guidance.
26
+ */
27
+ import type { MatchState } from '@table-games-engine/core';
28
+ /**
29
+ * A snapshot cadence policy.
30
+ *
31
+ * `shouldSnapshot` is invoked AFTER each engine transition. It receives:
32
+ * - `state`: the current `MatchState` AFTER the transition.
33
+ * - `sinceLast`: counters tracked by the wrapper since the last
34
+ * snapshot was taken. `transitionsSince` and `ticksSince` are both
35
+ * `0` immediately after a snapshot fires.
36
+ *
37
+ * Implementations MUST be pure — same arguments produce same answer.
38
+ */
39
+ export interface SnapshotPolicy {
40
+ shouldSnapshot(state: MatchState, sinceLast: {
41
+ transitionsSince: number;
42
+ ticksSince: number;
43
+ }): boolean;
44
+ }
45
+ /**
46
+ * Snapshot once every `n` transitions (`n >= 1`).
47
+ *
48
+ * `n=1` is equivalent to {@link always}. `n=0` would never trigger and is
49
+ * disallowed — use {@link never} explicitly when you mean "off".
50
+ */
51
+ export declare function everyNTransitions(n: number): SnapshotPolicy;
52
+ /**
53
+ * Snapshot once every `t` logical ticks (`t >= 1`).
54
+ *
55
+ * Useful when "transitions" is too granular (e.g. lots of trigger
56
+ * processing per logical tick). `ticksSince` is the engine's logical
57
+ * `state.tick`, NOT wall-clock time.
58
+ */
59
+ export declare function everyTTicks(t: number): SnapshotPolicy;
60
+ /**
61
+ * Snapshot every transition. Equivalent to `everyNTransitions(1)`.
62
+ *
63
+ * Recommended for tests (round-trip determinism) and for async-mode
64
+ * games where every action persists.
65
+ */
66
+ export declare function always(): SnapshotPolicy;
67
+ /**
68
+ * Never snapshot. Useful for benchmarking the engine without persistence
69
+ * overhead, or for read-only "audit" engines that only need the event log.
70
+ */
71
+ export declare function never(): SnapshotPolicy;
72
+ /**
73
+ * Disjunction of policies: snapshot if **any** sub-policy returns `true`.
74
+ *
75
+ * Empty input returns a policy equivalent to {@link never}.
76
+ *
77
+ * The first policy that returns `true` short-circuits evaluation, so cheap
78
+ * predicates should come first.
79
+ */
80
+ export declare function combine(...policies: SnapshotPolicy[]): SnapshotPolicy;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @fileoverview SQLite-backed `EngineAdapter` implementation.
3
+ *
4
+ * Uses `better-sqlite3` (synchronous, native bindings). Each `EngineAdapter`
5
+ * method wraps its work in `Promise.resolve(...)` to satisfy the async
6
+ * contract — but no real async I/O is performed.
7
+ *
8
+ * Multi-row writes (event batches, ownership upserts, snapshot inserts) are
9
+ * wrapped in transactions for atomicity. Snapshots and loads run integrity
10
+ * checks via `checksumState`.
11
+ *
12
+ * NODE-ONLY: This module imports `better-sqlite3` and `node:fs` (via
13
+ * `migrations.ts`). It MUST NOT be imported from `@table-games-engine/core`.
14
+ */
15
+ import type { EngineAdapter, GameEvent, ImmutableList, MatchId, MatchState, StateVersion } from '@table-games-engine/core';
16
+ export interface SqliteAdapterOptions {
17
+ /** Injectable clock for deterministic lease tests. Defaults to `Date.now`. */
18
+ now?: () => number;
19
+ }
20
+ /**
21
+ * SQLite-backed `EngineAdapter`.
22
+ *
23
+ * Construct with a filesystem path or `':memory:'`. Migrations are applied
24
+ * eagerly; the database handle is owned by this adapter and released by
25
+ * `close()`.
26
+ */
27
+ export declare class SqliteAdapter implements EngineAdapter {
28
+ private readonly db;
29
+ private readonly now;
30
+ private readonly stmtInsertSnapshot;
31
+ private readonly stmtSelectSnapshotChecksum;
32
+ private readonly stmtSelectLatestSnapshot;
33
+ private readonly stmtSelectSnapshotAtVersion;
34
+ private readonly stmtMaxEventVersion;
35
+ private readonly stmtInsertEvent;
36
+ private readonly stmtSelectEvents;
37
+ private readonly stmtSelectOwnership;
38
+ private readonly stmtUpsertOwnership;
39
+ private readonly stmtDeleteOwnership;
40
+ constructor(path: string, opts?: SqliteAdapterOptions);
41
+ persistSnapshot(matchId: MatchId, state: MatchState, checksum: string): Promise<void>;
42
+ loadSnapshot(matchId: MatchId, atVersion?: StateVersion): Promise<MatchState | null>;
43
+ appendEvents(matchId: MatchId, transitionVersion: StateVersion, events: ImmutableList<GameEvent>): Promise<void>;
44
+ loadEvents(matchId: MatchId, fromVersion: StateVersion, toVersion: StateVersion): Promise<ImmutableList<GameEvent>>;
45
+ acquireOwnership(matchId: MatchId, ownerNode: string, leaseSeconds: number): Promise<boolean>;
46
+ releaseOwnership(matchId: MatchId, ownerNode: string): Promise<void>;
47
+ /**
48
+ * Close the underlying database. Subsequent calls into adapter methods will
49
+ * throw with the better-sqlite3 native error ("The database connection is
50
+ * not open"); we do not wrap that to keep the contract minimal.
51
+ *
52
+ * Idempotent: calling `close()` twice is a no-op (better-sqlite3 tolerates
53
+ * the second close cleanly when the handle is already released).
54
+ */
55
+ close(): Promise<void>;
56
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview SQLite migrations for the persistence package.
3
+ *
4
+ * `applyMigrations` reads the colocated `schema.sql` at runtime and applies
5
+ * its DDL inside a single transaction. Idempotent: every statement uses
6
+ * IF NOT EXISTS, so re-running on a populated database is safe.
7
+ *
8
+ * The `_meta` table tracks `schema_version` for future migrations. Initial
9
+ * version is `1`. Schema-version helpers (`getSchemaVersion`,
10
+ * `setSchemaVersion`) are exported for forward compatibility.
11
+ *
12
+ * NOTE: This file is Node-only — it uses `fs.readFileSync` and the
13
+ * `better-sqlite3` Database type. It MUST NOT be imported from
14
+ * `@table-games-engine/core`.
15
+ */
16
+ import type Database from 'better-sqlite3';
17
+ /** Initial schema version — bump only when adding migrations. */
18
+ export declare const CURRENT_SCHEMA_VERSION = 1;
19
+ /**
20
+ * Apply the SQL schema to `db`. Idempotent: safe to run on every adapter
21
+ * construction.
22
+ */
23
+ export declare function applyMigrations(db: Database.Database): void;
24
+ /**
25
+ * Read the persisted schema version. Returns `0` when no version is recorded
26
+ * yet — including the case where the `_meta` table itself does not exist
27
+ * (i.e. brand-new database before `applyMigrations`).
28
+ */
29
+ export declare function getSchemaVersion(db: Database.Database): number;
30
+ /** Persist the schema version (used by `applyMigrations` and future runners). */
31
+ export declare function setSchemaVersion(db: Database.Database, n: number): void;
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@table-games-engine/persistence",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./core": {
16
+ "types": "./dist/core.d.ts",
17
+ "default": "./dist/core.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "build": "esbuild src/index.ts src/core.ts --bundle --format=esm --platform=node --packages=external --outdir=dist && tsc -p tsconfig.build.json",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest"
27
+ },
28
+ "dependencies": {
29
+ "@table-games-engine/core": "^0.1.1",
30
+ "better-sqlite3": "^11.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/better-sqlite3": "^7.6.0",
34
+ "typescript": "^5.4.0",
35
+ "vitest": "^2.0.0"
36
+ },
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"
48
+ }