@pickleball/expo-sdk 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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app.plugin.js +6 -0
- package/dist/chunk-VHLUJQG5.js +17 -0
- package/dist/index.cjs +6284 -0
- package/dist/index.d.cts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +6186 -0
- package/dist/plugin.cjs +153 -0
- package/dist/plugin.d.cts +21 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +131 -0
- package/dist/version.cjs +35 -0
- package/dist/version.d.cts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +126 -0
- package/src/contracts.ts +211 -0
- package/src/engine.ts +1332 -0
- package/src/errors.ts +128 -0
- package/src/http-session-provider.ts +259 -0
- package/src/index.ts +16 -0
- package/src/livekit-room-adapter.ts +353 -0
- package/src/livestream-screen.tsx +980 -0
- package/src/match/contracts.ts +137 -0
- package/src/match/engine.ts +702 -0
- package/src/match/persistence/default-store.native.ts +20 -0
- package/src/match/persistence/default-store.ts +11 -0
- package/src/match/persistence/index.ts +6 -0
- package/src/match/persistence/memory-driver.ts +255 -0
- package/src/match/persistence/migrations.ts +40 -0
- package/src/match/persistence/sqlite-driver.ts +348 -0
- package/src/match/persistence/store.ts +497 -0
- package/src/match/persistence/types.ts +98 -0
- package/src/match/provider.tsx +728 -0
- package/src/match/remote/deduplicator.ts +54 -0
- package/src/match/remote/encoding.ts +12 -0
- package/src/match/remote/hid-gesture.ts +61 -0
- package/src/match/remote/index.ts +5 -0
- package/src/match/remote/native-service.ts +62 -0
- package/src/match/remote/parser.ts +134 -0
- package/src/match/remote/types.ts +116 -0
- package/src/match/replay.ts +102 -0
- package/src/match/state.ts +72 -0
- package/src/match/validation.ts +375 -0
- package/src/match/voice/assets/court-left.wav +0 -0
- package/src/match/voice/assets/court-right.wav +0 -0
- package/src/match/voice/assets/court-switch.wav +0 -0
- package/src/match/voice/assets/game-point.wav +0 -0
- package/src/match/voice/assets/game-won.wav +0 -0
- package/src/match/voice/assets/match-won.wav +0 -0
- package/src/match/voice/assets/number-0.wav +0 -0
- package/src/match/voice/assets/number-1.wav +0 -0
- package/src/match/voice/assets/number-10.wav +0 -0
- package/src/match/voice/assets/number-11.wav +0 -0
- package/src/match/voice/assets/number-12.wav +0 -0
- package/src/match/voice/assets/number-13.wav +0 -0
- package/src/match/voice/assets/number-14.wav +0 -0
- package/src/match/voice/assets/number-15.wav +0 -0
- package/src/match/voice/assets/number-16.wav +0 -0
- package/src/match/voice/assets/number-17.wav +0 -0
- package/src/match/voice/assets/number-18.wav +0 -0
- package/src/match/voice/assets/number-19.wav +0 -0
- package/src/match/voice/assets/number-2.wav +0 -0
- package/src/match/voice/assets/number-20.wav +0 -0
- package/src/match/voice/assets/number-3.wav +0 -0
- package/src/match/voice/assets/number-30.wav +0 -0
- package/src/match/voice/assets/number-4.wav +0 -0
- package/src/match/voice/assets/number-40.wav +0 -0
- package/src/match/voice/assets/number-5.wav +0 -0
- package/src/match/voice/assets/number-50.wav +0 -0
- package/src/match/voice/assets/number-6.wav +0 -0
- package/src/match/voice/assets/number-60.wav +0 -0
- package/src/match/voice/assets/number-7.wav +0 -0
- package/src/match/voice/assets/number-70.wav +0 -0
- package/src/match/voice/assets/number-8.wav +0 -0
- package/src/match/voice/assets/number-80.wav +0 -0
- package/src/match/voice/assets/number-9.wav +0 -0
- package/src/match/voice/assets/number-90.wav +0 -0
- package/src/match/voice/assets/number-lam.wav +0 -0
- package/src/match/voice/assets/number-mot.wav +0 -0
- package/src/match/voice/assets/score-deu-0.wav +0 -0
- package/src/match/voice/assets/score-deu-1.wav +0 -0
- package/src/match/voice/assets/score-deu-10.wav +0 -0
- package/src/match/voice/assets/score-deu-11.wav +0 -0
- package/src/match/voice/assets/score-deu-12.wav +0 -0
- package/src/match/voice/assets/score-deu-13.wav +0 -0
- package/src/match/voice/assets/score-deu-14.wav +0 -0
- package/src/match/voice/assets/score-deu-15.wav +0 -0
- package/src/match/voice/assets/score-deu-16.wav +0 -0
- package/src/match/voice/assets/score-deu-17.wav +0 -0
- package/src/match/voice/assets/score-deu-18.wav +0 -0
- package/src/match/voice/assets/score-deu-19.wav +0 -0
- package/src/match/voice/assets/score-deu-2.wav +0 -0
- package/src/match/voice/assets/score-deu-20.wav +0 -0
- package/src/match/voice/assets/score-deu-3.wav +0 -0
- package/src/match/voice/assets/score-deu-30.wav +0 -0
- package/src/match/voice/assets/score-deu-4.wav +0 -0
- package/src/match/voice/assets/score-deu-40.wav +0 -0
- package/src/match/voice/assets/score-deu-5.wav +0 -0
- package/src/match/voice/assets/score-deu-50.wav +0 -0
- package/src/match/voice/assets/score-deu-6.wav +0 -0
- package/src/match/voice/assets/score-deu-60.wav +0 -0
- package/src/match/voice/assets/score-deu-7.wav +0 -0
- package/src/match/voice/assets/score-deu-70.wav +0 -0
- package/src/match/voice/assets/score-deu-8.wav +0 -0
- package/src/match/voice/assets/score-deu-80.wav +0 -0
- package/src/match/voice/assets/score-deu-9.wav +0 -0
- package/src/match/voice/assets/score-deu-90.wav +0 -0
- package/src/match/voice/assets/score-deu-lam.wav +0 -0
- package/src/match/voice/assets/score-deu-mot.wav +0 -0
- package/src/match/voice/assets/server-1.wav +0 -0
- package/src/match/voice/assets/server-2.wav +0 -0
- package/src/match/voice/assets/service-change.wav +0 -0
- package/src/match/voice/assets/test-speaker.wav +0 -0
- package/src/match/voice/audio.ts +231 -0
- package/src/match/voice/bundled-assets.native.ts +47 -0
- package/src/match/voice/bundled-assets.ts +3 -0
- package/src/match/voice/gemini-engine.ts +167 -0
- package/src/match/voice/index.ts +7 -0
- package/src/match/voice/phrases.ts +185 -0
- package/src/match/voice/scheduler.ts +205 -0
- package/src/match/voice/service.ts +147 -0
- package/src/match/voice/types.ts +118 -0
- package/src/native-runtime.ts +241 -0
- package/src/plugin.ts +145 -0
- package/src/provider.tsx +176 -0
- package/src/version.ts +4 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MatchCreateInput,
|
|
3
|
+
MatchEvent,
|
|
4
|
+
MatchInputSource,
|
|
5
|
+
MatchSnapshot,
|
|
6
|
+
} from "../contracts";
|
|
7
|
+
import { applyMatchEvent, createMatch as createMatchState } from "../engine";
|
|
8
|
+
import { replayMatchEvents } from "../replay";
|
|
9
|
+
import { valuesEqual } from "../state";
|
|
10
|
+
import {
|
|
11
|
+
assertRuntimeMatchEvent,
|
|
12
|
+
assertRuntimeMatchSnapshot,
|
|
13
|
+
} from "../validation";
|
|
14
|
+
import type {
|
|
15
|
+
MatchPersistenceDriver,
|
|
16
|
+
MatchSummary,
|
|
17
|
+
PersistedMatch,
|
|
18
|
+
StoredMatchEventRow,
|
|
19
|
+
StoredMatchRow,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import type { RemoteBinding } from "../remote/types";
|
|
22
|
+
import {
|
|
23
|
+
MATCH_PERSISTENCE_SCHEMA_VERSION,
|
|
24
|
+
MatchPersistenceError,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
interface MatchEventStoreOptions {
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asPersistenceError(error: unknown): MatchPersistenceError {
|
|
32
|
+
if (error instanceof MatchPersistenceError) return error;
|
|
33
|
+
return new MatchPersistenceError(
|
|
34
|
+
"storage_failure",
|
|
35
|
+
"The local match database operation failed",
|
|
36
|
+
{ cause: error },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJson(json: string, label: string): unknown {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(json) as unknown;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new MatchPersistenceError("corrupt_data", `${label} contains malformed JSON`, {
|
|
45
|
+
cause: error,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function snapshotRow(snapshot: MatchSnapshot, updatedAt: number): StoredMatchRow {
|
|
51
|
+
return {
|
|
52
|
+
matchId: snapshot.id,
|
|
53
|
+
schemaVersion: MATCH_PERSISTENCE_SCHEMA_VERSION,
|
|
54
|
+
snapshotJson: JSON.stringify(snapshot),
|
|
55
|
+
snapshotSequence: snapshot.sequence,
|
|
56
|
+
status: snapshot.status,
|
|
57
|
+
updatedAt,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function eventRow(matchId: string, event: MatchEvent, createdAt: number): StoredMatchEventRow {
|
|
62
|
+
return {
|
|
63
|
+
matchId,
|
|
64
|
+
sequence: event.sequence,
|
|
65
|
+
eventId: event.id,
|
|
66
|
+
schemaVersion: event.schemaVersion,
|
|
67
|
+
eventJson: JSON.stringify(event),
|
|
68
|
+
createdAt,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function decodePersistedMatch(
|
|
73
|
+
matchId: string,
|
|
74
|
+
row: StoredMatchRow,
|
|
75
|
+
eventRows: readonly StoredMatchEventRow[],
|
|
76
|
+
): PersistedMatch {
|
|
77
|
+
if (
|
|
78
|
+
row.schemaVersion !== MATCH_PERSISTENCE_SCHEMA_VERSION ||
|
|
79
|
+
eventRows.some((event) => event.schemaVersion !== MATCH_PERSISTENCE_SCHEMA_VERSION)
|
|
80
|
+
) {
|
|
81
|
+
throw new MatchPersistenceError(
|
|
82
|
+
"unsupported_schema",
|
|
83
|
+
"The stored match uses an unsupported schema version",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (eventRows.length === 0) {
|
|
87
|
+
throw new MatchPersistenceError("corrupt_data", "The match creation event is missing");
|
|
88
|
+
}
|
|
89
|
+
eventRows.forEach((storedEvent, index) => {
|
|
90
|
+
if (
|
|
91
|
+
storedEvent.matchId !== matchId ||
|
|
92
|
+
storedEvent.sequence !== index ||
|
|
93
|
+
storedEvent.eventId !== `${matchId}:${index}`
|
|
94
|
+
) {
|
|
95
|
+
throw new MatchPersistenceError(
|
|
96
|
+
"sequence_mismatch",
|
|
97
|
+
"The stored match event sequence is not contiguous",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const events = eventRows.map((storedEvent) => {
|
|
102
|
+
const value = parseJson(storedEvent.eventJson, "Stored match event");
|
|
103
|
+
try {
|
|
104
|
+
assertRuntimeMatchEvent(value);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new MatchPersistenceError("corrupt_data", "A stored match event is invalid", {
|
|
107
|
+
cause: error,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
value.id !== storedEvent.eventId ||
|
|
112
|
+
value.sequence !== storedEvent.sequence ||
|
|
113
|
+
value.schemaVersion !== storedEvent.schemaVersion
|
|
114
|
+
) {
|
|
115
|
+
throw new MatchPersistenceError(
|
|
116
|
+
"sequence_mismatch",
|
|
117
|
+
"A stored event does not match its database ordering metadata",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
});
|
|
122
|
+
let replayed: MatchSnapshot;
|
|
123
|
+
try {
|
|
124
|
+
replayed = replayMatchEvents(events);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new MatchPersistenceError("corrupt_data", "The stored event log cannot be replayed", {
|
|
127
|
+
cause: error,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const cachedValue = parseJson(row.snapshotJson, "Stored match snapshot");
|
|
131
|
+
try {
|
|
132
|
+
assertRuntimeMatchSnapshot(cachedValue);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw new MatchPersistenceError("corrupt_data", "The stored match snapshot is invalid", {
|
|
135
|
+
cause: error,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
row.matchId !== replayed.id ||
|
|
140
|
+
row.snapshotSequence !== replayed.sequence ||
|
|
141
|
+
row.status !== replayed.status ||
|
|
142
|
+
!valuesEqual(cachedValue, replayed)
|
|
143
|
+
) {
|
|
144
|
+
throw new MatchPersistenceError(
|
|
145
|
+
"snapshot_mismatch",
|
|
146
|
+
"The snapshot cache does not match the immutable event log",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return { snapshot: replayed, events };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readPersistedMatch(
|
|
153
|
+
driver: MatchPersistenceDriver,
|
|
154
|
+
matchId: string,
|
|
155
|
+
): Promise<PersistedMatch | null> {
|
|
156
|
+
const { match, events } = await driver.readMatch(matchId);
|
|
157
|
+
return match ? decodePersistedMatch(matchId, match, events) : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resolveCreateRetry(
|
|
161
|
+
existing: PersistedMatch,
|
|
162
|
+
requested: PersistedMatch,
|
|
163
|
+
): PersistedMatch {
|
|
164
|
+
if (valuesEqual(existing.events[0], requested.events[0])) {
|
|
165
|
+
return existing;
|
|
166
|
+
}
|
|
167
|
+
throw new MatchPersistenceError(
|
|
168
|
+
"already_exists",
|
|
169
|
+
"A different match already uses this match id",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveAppendRetry(
|
|
174
|
+
persisted: PersistedMatch,
|
|
175
|
+
currentSnapshot: MatchSnapshot,
|
|
176
|
+
event: MatchEvent,
|
|
177
|
+
): PersistedMatch | null {
|
|
178
|
+
if (
|
|
179
|
+
event.sequence !== currentSnapshot.sequence + 1 ||
|
|
180
|
+
event.id !== `${currentSnapshot.id}:${event.sequence}`
|
|
181
|
+
) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const existingEvent = persisted.events[event.sequence];
|
|
185
|
+
if (!existingEvent) return null;
|
|
186
|
+
|
|
187
|
+
let snapshotBeforeEvent: MatchSnapshot;
|
|
188
|
+
try {
|
|
189
|
+
snapshotBeforeEvent = replayMatchEvents(persisted.events.slice(0, event.sequence));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw new MatchPersistenceError(
|
|
192
|
+
"corrupt_data",
|
|
193
|
+
"The persisted event prefix cannot be replayed",
|
|
194
|
+
{ cause: error },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (!valuesEqual(snapshotBeforeEvent, currentSnapshot)) {
|
|
198
|
+
throw new MatchPersistenceError(
|
|
199
|
+
"snapshot_mismatch",
|
|
200
|
+
"The caller snapshot does not match the event's persisted prefix",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (!valuesEqual(existingEvent, event)) {
|
|
204
|
+
throw new MatchPersistenceError(
|
|
205
|
+
"conflict",
|
|
206
|
+
"A different event already exists at this match sequence",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return persisted;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertMatchListMetadata(row: StoredMatchRow) {
|
|
213
|
+
if (row.schemaVersion !== MATCH_PERSISTENCE_SCHEMA_VERSION) {
|
|
214
|
+
throw new MatchPersistenceError(
|
|
215
|
+
"unsupported_schema",
|
|
216
|
+
"A stored match summary uses an unsupported schema version",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
typeof row.matchId !== "string" ||
|
|
221
|
+
row.matchId.trim().length === 0 ||
|
|
222
|
+
typeof row.snapshotJson !== "string" ||
|
|
223
|
+
!Number.isSafeInteger(row.snapshotSequence) ||
|
|
224
|
+
row.snapshotSequence < 0 ||
|
|
225
|
+
(row.status !== "inProgress" &&
|
|
226
|
+
row.status !== "completed" &&
|
|
227
|
+
row.status !== "ended") ||
|
|
228
|
+
!Number.isSafeInteger(row.updatedAt) ||
|
|
229
|
+
row.updatedAt < 0
|
|
230
|
+
) {
|
|
231
|
+
throw new MatchPersistenceError(
|
|
232
|
+
"corrupt_data",
|
|
233
|
+
"A stored match summary contains invalid metadata",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class MatchEventStore {
|
|
239
|
+
private initialized: Promise<void> | null = null;
|
|
240
|
+
private readonly now: () => number;
|
|
241
|
+
|
|
242
|
+
constructor(
|
|
243
|
+
private readonly driver: MatchPersistenceDriver,
|
|
244
|
+
options: MatchEventStoreOptions = {},
|
|
245
|
+
) {
|
|
246
|
+
this.now = options.now ?? Date.now;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
initialize(): Promise<void> {
|
|
250
|
+
if (!this.initialized) {
|
|
251
|
+
this.initialized = this.driver.migrate().catch((error) => {
|
|
252
|
+
this.initialized = null;
|
|
253
|
+
throw asPersistenceError(error);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return this.initialized;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async getRemoteBinding(): Promise<RemoteBinding | null> {
|
|
260
|
+
await this.initialize();
|
|
261
|
+
if (!this.driver.getRemoteBinding) return null;
|
|
262
|
+
try {
|
|
263
|
+
return await this.driver.getRemoteBinding();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw asPersistenceError(error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async setRemoteBinding(binding: RemoteBinding | null): Promise<void> {
|
|
270
|
+
if (binding && binding.deviceId.trim().length === 0) {
|
|
271
|
+
throw new MatchPersistenceError("corrupt_data", "Remote binding identifier cannot be empty");
|
|
272
|
+
}
|
|
273
|
+
await this.initialize();
|
|
274
|
+
if (!this.driver.setRemoteBinding) {
|
|
275
|
+
throw new MatchPersistenceError("storage_failure", "Remote binding storage is unavailable");
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
await this.driver.setRemoteBinding(binding);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
throw asPersistenceError(error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async createMatch(
|
|
285
|
+
input: MatchCreateInput,
|
|
286
|
+
source: MatchInputSource = "system",
|
|
287
|
+
): Promise<PersistedMatch> {
|
|
288
|
+
await this.initialize();
|
|
289
|
+
const created = createMatchState(input, source);
|
|
290
|
+
const timestamp = this.now();
|
|
291
|
+
try {
|
|
292
|
+
return await this.driver.transaction(async (transaction) => {
|
|
293
|
+
const [existingRow, existingEvents] = await Promise.all([
|
|
294
|
+
transaction.getMatch(created.snapshot.id),
|
|
295
|
+
transaction.getEvents(created.snapshot.id),
|
|
296
|
+
]);
|
|
297
|
+
if (existingRow) {
|
|
298
|
+
return resolveCreateRetry(
|
|
299
|
+
decodePersistedMatch(created.snapshot.id, existingRow, existingEvents),
|
|
300
|
+
created,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (existingEvents.length > 0) {
|
|
304
|
+
throw new MatchPersistenceError(
|
|
305
|
+
"corrupt_data",
|
|
306
|
+
"Match events exist without their aggregate",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
await transaction.insertMatch(snapshotRow(created.snapshot, timestamp));
|
|
310
|
+
await transaction.insertEvent(eventRow(created.snapshot.id, created.events[0], timestamp));
|
|
311
|
+
await transaction.setActiveMatch(created.snapshot.id);
|
|
312
|
+
return created;
|
|
313
|
+
});
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error instanceof MatchPersistenceError) throw error;
|
|
316
|
+
try {
|
|
317
|
+
const existing = await readPersistedMatch(this.driver, created.snapshot.id);
|
|
318
|
+
if (existing) return resolveCreateRetry(existing, created);
|
|
319
|
+
} catch (reconciliationError) {
|
|
320
|
+
if (reconciliationError instanceof MatchPersistenceError) {
|
|
321
|
+
throw reconciliationError;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
throw asPersistenceError(error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async appendEvent(
|
|
329
|
+
currentSnapshot: MatchSnapshot,
|
|
330
|
+
event: MatchEvent,
|
|
331
|
+
): Promise<PersistedMatch> {
|
|
332
|
+
await this.initialize();
|
|
333
|
+
const timestamp = this.now();
|
|
334
|
+
try {
|
|
335
|
+
return await this.driver.transaction(async (transaction) => {
|
|
336
|
+
const [row, eventRows] = await Promise.all([
|
|
337
|
+
transaction.getMatch(currentSnapshot.id),
|
|
338
|
+
transaction.getEvents(currentSnapshot.id),
|
|
339
|
+
]);
|
|
340
|
+
if (!row) {
|
|
341
|
+
throw new MatchPersistenceError("not_found", "The match to update is missing");
|
|
342
|
+
}
|
|
343
|
+
const persisted = decodePersistedMatch(currentSnapshot.id, row, eventRows);
|
|
344
|
+
const retried = resolveAppendRetry(persisted, currentSnapshot, event);
|
|
345
|
+
if (retried) return retried;
|
|
346
|
+
if (!valuesEqual(persisted.snapshot, currentSnapshot)) {
|
|
347
|
+
throw new MatchPersistenceError(
|
|
348
|
+
"snapshot_mismatch",
|
|
349
|
+
"The caller snapshot does not match the immutable event log",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
let nextSnapshot: MatchSnapshot;
|
|
353
|
+
try {
|
|
354
|
+
nextSnapshot = applyMatchEvent(currentSnapshot, event);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
throw new MatchPersistenceError(
|
|
357
|
+
"sequence_mismatch",
|
|
358
|
+
"The match event cannot follow the current snapshot",
|
|
359
|
+
{ cause: error },
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
await transaction.insertEvent(eventRow(currentSnapshot.id, event, timestamp));
|
|
363
|
+
const updated = await transaction.updateSnapshot(
|
|
364
|
+
currentSnapshot.id,
|
|
365
|
+
currentSnapshot.sequence,
|
|
366
|
+
snapshotRow(nextSnapshot, timestamp),
|
|
367
|
+
);
|
|
368
|
+
if (!updated) {
|
|
369
|
+
throw new MatchPersistenceError(
|
|
370
|
+
"sequence_mismatch",
|
|
371
|
+
"The persisted match sequence changed before this event committed",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (
|
|
375
|
+
currentSnapshot.status !== "inProgress" &&
|
|
376
|
+
nextSnapshot.status === "inProgress"
|
|
377
|
+
) {
|
|
378
|
+
await transaction.setActiveMatch(currentSnapshot.id);
|
|
379
|
+
} else if (
|
|
380
|
+
currentSnapshot.status === "inProgress" &&
|
|
381
|
+
nextSnapshot.status !== "inProgress"
|
|
382
|
+
) {
|
|
383
|
+
await transaction.clearActiveMatch(currentSnapshot.id);
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
snapshot: nextSnapshot,
|
|
387
|
+
events: [...persisted.events, event],
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error instanceof MatchPersistenceError) throw error;
|
|
392
|
+
try {
|
|
393
|
+
const persisted = await readPersistedMatch(this.driver, currentSnapshot.id);
|
|
394
|
+
if (persisted) {
|
|
395
|
+
const retried = resolveAppendRetry(persisted, currentSnapshot, event);
|
|
396
|
+
if (retried) return retried;
|
|
397
|
+
}
|
|
398
|
+
} catch (reconciliationError) {
|
|
399
|
+
if (reconciliationError instanceof MatchPersistenceError) {
|
|
400
|
+
throw reconciliationError;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
throw asPersistenceError(error);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async resumeActiveMatch(): Promise<PersistedMatch | null> {
|
|
408
|
+
await this.initialize();
|
|
409
|
+
let activeMatchId: string | null;
|
|
410
|
+
let row: StoredMatchRow | null;
|
|
411
|
+
let eventRows: readonly StoredMatchEventRow[];
|
|
412
|
+
try {
|
|
413
|
+
({ activeMatchId, match: row, events: eventRows } =
|
|
414
|
+
await this.driver.readActiveMatch());
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throw asPersistenceError(error);
|
|
417
|
+
}
|
|
418
|
+
if (!activeMatchId) return null;
|
|
419
|
+
if (!row) {
|
|
420
|
+
throw new MatchPersistenceError(
|
|
421
|
+
"corrupt_data",
|
|
422
|
+
"The active match pointer has no aggregate",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const persisted = decodePersistedMatch(activeMatchId, row, eventRows);
|
|
426
|
+
if (persisted.snapshot.status !== "inProgress") {
|
|
427
|
+
throw new MatchPersistenceError(
|
|
428
|
+
"corrupt_data",
|
|
429
|
+
"The active match pointer targets a non-active match",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
return persisted;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async resumeMatch(matchId: string): Promise<PersistedMatch | null> {
|
|
436
|
+
await this.initialize();
|
|
437
|
+
let row: StoredMatchRow | null;
|
|
438
|
+
let eventRows: readonly StoredMatchEventRow[];
|
|
439
|
+
try {
|
|
440
|
+
({ match: row, events: eventRows } = await this.driver.readMatch(matchId));
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throw asPersistenceError(error);
|
|
443
|
+
}
|
|
444
|
+
if (!row) return null;
|
|
445
|
+
return decodePersistedMatch(matchId, row, eventRows);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async listMatches(): Promise<readonly MatchSummary[]> {
|
|
449
|
+
await this.initialize();
|
|
450
|
+
try {
|
|
451
|
+
const { matches, activeMatchId } = await this.driver.readMatchList();
|
|
452
|
+
matches.forEach(assertMatchListMetadata);
|
|
453
|
+
const ids = new Set(matches.map((row) => row.matchId));
|
|
454
|
+
if (ids.size !== matches.length) {
|
|
455
|
+
throw new MatchPersistenceError(
|
|
456
|
+
"corrupt_data",
|
|
457
|
+
"The stored match list contains duplicate match ids",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (activeMatchId !== null && !ids.has(activeMatchId)) {
|
|
461
|
+
throw new MatchPersistenceError(
|
|
462
|
+
"corrupt_data",
|
|
463
|
+
"The active match pointer is missing from the stored match list",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (
|
|
467
|
+
activeMatchId !== null &&
|
|
468
|
+
matches.find((row) => row.matchId === activeMatchId)?.status !== "inProgress"
|
|
469
|
+
) {
|
|
470
|
+
throw new MatchPersistenceError(
|
|
471
|
+
"corrupt_data",
|
|
472
|
+
"The active match pointer targets a non-active match summary",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return matches.map((row) => ({
|
|
476
|
+
id: row.matchId,
|
|
477
|
+
sequence: row.snapshotSequence,
|
|
478
|
+
status: row.status,
|
|
479
|
+
updatedAt: row.updatedAt,
|
|
480
|
+
isActive: row.matchId === activeMatchId,
|
|
481
|
+
}));
|
|
482
|
+
} catch (error) {
|
|
483
|
+
throw asPersistenceError(error);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async archiveMatch(matchId: string): Promise<void> {
|
|
488
|
+
await this.initialize();
|
|
489
|
+
try {
|
|
490
|
+
await this.driver.transaction(async (transaction) => {
|
|
491
|
+
await transaction.clearActiveMatch(matchId);
|
|
492
|
+
});
|
|
493
|
+
} catch (error) {
|
|
494
|
+
throw asPersistenceError(error);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { MatchEvent, MatchSnapshot } from "../contracts";
|
|
2
|
+
import type { RemoteBinding } from "../remote/types";
|
|
3
|
+
|
|
4
|
+
export const MATCH_PERSISTENCE_SCHEMA_VERSION = 1 as const;
|
|
5
|
+
|
|
6
|
+
export interface StoredMatchRow {
|
|
7
|
+
readonly matchId: string;
|
|
8
|
+
readonly schemaVersion: number;
|
|
9
|
+
readonly snapshotJson: string;
|
|
10
|
+
readonly snapshotSequence: number;
|
|
11
|
+
readonly status: MatchSnapshot["status"];
|
|
12
|
+
readonly updatedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StoredMatchEventRow {
|
|
16
|
+
readonly matchId: string;
|
|
17
|
+
readonly sequence: number;
|
|
18
|
+
readonly eventId: string;
|
|
19
|
+
readonly schemaVersion: number;
|
|
20
|
+
readonly eventJson: string;
|
|
21
|
+
readonly createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MatchPersistenceTransaction {
|
|
25
|
+
getMatch(matchId: string): Promise<StoredMatchRow | null>;
|
|
26
|
+
getEvents(matchId: string): Promise<readonly StoredMatchEventRow[]>;
|
|
27
|
+
insertMatch(row: StoredMatchRow): Promise<void>;
|
|
28
|
+
insertEvent(row: StoredMatchEventRow): Promise<void>;
|
|
29
|
+
updateSnapshot(
|
|
30
|
+
matchId: string,
|
|
31
|
+
expectedSequence: number,
|
|
32
|
+
row: Pick<
|
|
33
|
+
StoredMatchRow,
|
|
34
|
+
"schemaVersion" | "snapshotJson" | "snapshotSequence" | "status" | "updatedAt"
|
|
35
|
+
>,
|
|
36
|
+
): Promise<boolean>;
|
|
37
|
+
setActiveMatch(matchId: string | null): Promise<void>;
|
|
38
|
+
clearActiveMatch(matchId: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MatchPersistenceDriver {
|
|
42
|
+
migrate(): Promise<void>;
|
|
43
|
+
transaction<T>(work: (transaction: MatchPersistenceTransaction) => Promise<T>): Promise<T>;
|
|
44
|
+
readMatch(matchId: string): Promise<{
|
|
45
|
+
readonly match: StoredMatchRow | null;
|
|
46
|
+
readonly events: readonly StoredMatchEventRow[];
|
|
47
|
+
}>;
|
|
48
|
+
getActiveMatchId(): Promise<string | null>;
|
|
49
|
+
readActiveMatch(): Promise<{
|
|
50
|
+
readonly activeMatchId: string | null;
|
|
51
|
+
readonly match: StoredMatchRow | null;
|
|
52
|
+
readonly events: readonly StoredMatchEventRow[];
|
|
53
|
+
}>;
|
|
54
|
+
readMatchList(): Promise<{
|
|
55
|
+
readonly matches: readonly StoredMatchRow[];
|
|
56
|
+
readonly activeMatchId: string | null;
|
|
57
|
+
}>;
|
|
58
|
+
getRemoteBinding?(): Promise<RemoteBinding | null>;
|
|
59
|
+
setRemoteBinding?(binding: RemoteBinding | null): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PersistedMatch {
|
|
63
|
+
readonly snapshot: MatchSnapshot;
|
|
64
|
+
readonly events: readonly MatchEvent[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MatchSummary {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly sequence: number;
|
|
70
|
+
readonly status: MatchSnapshot["status"];
|
|
71
|
+
readonly updatedAt: number;
|
|
72
|
+
readonly isActive: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type MatchPersistenceErrorCode =
|
|
76
|
+
| "not_found"
|
|
77
|
+
| "already_exists"
|
|
78
|
+
| "conflict"
|
|
79
|
+
| "corrupt_data"
|
|
80
|
+
| "unsupported_schema"
|
|
81
|
+
| "sequence_mismatch"
|
|
82
|
+
| "snapshot_mismatch"
|
|
83
|
+
| "storage_failure";
|
|
84
|
+
|
|
85
|
+
export class MatchPersistenceError extends Error {
|
|
86
|
+
readonly name = "MatchPersistenceError";
|
|
87
|
+
readonly recoverable = true;
|
|
88
|
+
readonly retryable: boolean;
|
|
89
|
+
|
|
90
|
+
constructor(
|
|
91
|
+
readonly code: MatchPersistenceErrorCode,
|
|
92
|
+
message: string,
|
|
93
|
+
options?: { cause?: unknown },
|
|
94
|
+
) {
|
|
95
|
+
super(message, options);
|
|
96
|
+
this.retryable = code === "storage_failure";
|
|
97
|
+
}
|
|
98
|
+
}
|