@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app.plugin.js +6 -0
  5. package/dist/chunk-VHLUJQG5.js +17 -0
  6. package/dist/index.cjs +6284 -0
  7. package/dist/index.d.cts +1436 -0
  8. package/dist/index.d.ts +1436 -0
  9. package/dist/index.js +6186 -0
  10. package/dist/plugin.cjs +153 -0
  11. package/dist/plugin.d.cts +21 -0
  12. package/dist/plugin.d.ts +21 -0
  13. package/dist/plugin.js +131 -0
  14. package/dist/version.cjs +35 -0
  15. package/dist/version.d.cts +4 -0
  16. package/dist/version.d.ts +4 -0
  17. package/dist/version.js +6 -0
  18. package/package.json +126 -0
  19. package/src/contracts.ts +211 -0
  20. package/src/engine.ts +1332 -0
  21. package/src/errors.ts +128 -0
  22. package/src/http-session-provider.ts +259 -0
  23. package/src/index.ts +16 -0
  24. package/src/livekit-room-adapter.ts +353 -0
  25. package/src/livestream-screen.tsx +980 -0
  26. package/src/match/contracts.ts +137 -0
  27. package/src/match/engine.ts +702 -0
  28. package/src/match/persistence/default-store.native.ts +20 -0
  29. package/src/match/persistence/default-store.ts +11 -0
  30. package/src/match/persistence/index.ts +6 -0
  31. package/src/match/persistence/memory-driver.ts +255 -0
  32. package/src/match/persistence/migrations.ts +40 -0
  33. package/src/match/persistence/sqlite-driver.ts +348 -0
  34. package/src/match/persistence/store.ts +497 -0
  35. package/src/match/persistence/types.ts +98 -0
  36. package/src/match/provider.tsx +728 -0
  37. package/src/match/remote/deduplicator.ts +54 -0
  38. package/src/match/remote/encoding.ts +12 -0
  39. package/src/match/remote/hid-gesture.ts +61 -0
  40. package/src/match/remote/index.ts +5 -0
  41. package/src/match/remote/native-service.ts +62 -0
  42. package/src/match/remote/parser.ts +134 -0
  43. package/src/match/remote/types.ts +116 -0
  44. package/src/match/replay.ts +102 -0
  45. package/src/match/state.ts +72 -0
  46. package/src/match/validation.ts +375 -0
  47. package/src/match/voice/assets/court-left.wav +0 -0
  48. package/src/match/voice/assets/court-right.wav +0 -0
  49. package/src/match/voice/assets/court-switch.wav +0 -0
  50. package/src/match/voice/assets/game-point.wav +0 -0
  51. package/src/match/voice/assets/game-won.wav +0 -0
  52. package/src/match/voice/assets/match-won.wav +0 -0
  53. package/src/match/voice/assets/number-0.wav +0 -0
  54. package/src/match/voice/assets/number-1.wav +0 -0
  55. package/src/match/voice/assets/number-10.wav +0 -0
  56. package/src/match/voice/assets/number-11.wav +0 -0
  57. package/src/match/voice/assets/number-12.wav +0 -0
  58. package/src/match/voice/assets/number-13.wav +0 -0
  59. package/src/match/voice/assets/number-14.wav +0 -0
  60. package/src/match/voice/assets/number-15.wav +0 -0
  61. package/src/match/voice/assets/number-16.wav +0 -0
  62. package/src/match/voice/assets/number-17.wav +0 -0
  63. package/src/match/voice/assets/number-18.wav +0 -0
  64. package/src/match/voice/assets/number-19.wav +0 -0
  65. package/src/match/voice/assets/number-2.wav +0 -0
  66. package/src/match/voice/assets/number-20.wav +0 -0
  67. package/src/match/voice/assets/number-3.wav +0 -0
  68. package/src/match/voice/assets/number-30.wav +0 -0
  69. package/src/match/voice/assets/number-4.wav +0 -0
  70. package/src/match/voice/assets/number-40.wav +0 -0
  71. package/src/match/voice/assets/number-5.wav +0 -0
  72. package/src/match/voice/assets/number-50.wav +0 -0
  73. package/src/match/voice/assets/number-6.wav +0 -0
  74. package/src/match/voice/assets/number-60.wav +0 -0
  75. package/src/match/voice/assets/number-7.wav +0 -0
  76. package/src/match/voice/assets/number-70.wav +0 -0
  77. package/src/match/voice/assets/number-8.wav +0 -0
  78. package/src/match/voice/assets/number-80.wav +0 -0
  79. package/src/match/voice/assets/number-9.wav +0 -0
  80. package/src/match/voice/assets/number-90.wav +0 -0
  81. package/src/match/voice/assets/number-lam.wav +0 -0
  82. package/src/match/voice/assets/number-mot.wav +0 -0
  83. package/src/match/voice/assets/score-deu-0.wav +0 -0
  84. package/src/match/voice/assets/score-deu-1.wav +0 -0
  85. package/src/match/voice/assets/score-deu-10.wav +0 -0
  86. package/src/match/voice/assets/score-deu-11.wav +0 -0
  87. package/src/match/voice/assets/score-deu-12.wav +0 -0
  88. package/src/match/voice/assets/score-deu-13.wav +0 -0
  89. package/src/match/voice/assets/score-deu-14.wav +0 -0
  90. package/src/match/voice/assets/score-deu-15.wav +0 -0
  91. package/src/match/voice/assets/score-deu-16.wav +0 -0
  92. package/src/match/voice/assets/score-deu-17.wav +0 -0
  93. package/src/match/voice/assets/score-deu-18.wav +0 -0
  94. package/src/match/voice/assets/score-deu-19.wav +0 -0
  95. package/src/match/voice/assets/score-deu-2.wav +0 -0
  96. package/src/match/voice/assets/score-deu-20.wav +0 -0
  97. package/src/match/voice/assets/score-deu-3.wav +0 -0
  98. package/src/match/voice/assets/score-deu-30.wav +0 -0
  99. package/src/match/voice/assets/score-deu-4.wav +0 -0
  100. package/src/match/voice/assets/score-deu-40.wav +0 -0
  101. package/src/match/voice/assets/score-deu-5.wav +0 -0
  102. package/src/match/voice/assets/score-deu-50.wav +0 -0
  103. package/src/match/voice/assets/score-deu-6.wav +0 -0
  104. package/src/match/voice/assets/score-deu-60.wav +0 -0
  105. package/src/match/voice/assets/score-deu-7.wav +0 -0
  106. package/src/match/voice/assets/score-deu-70.wav +0 -0
  107. package/src/match/voice/assets/score-deu-8.wav +0 -0
  108. package/src/match/voice/assets/score-deu-80.wav +0 -0
  109. package/src/match/voice/assets/score-deu-9.wav +0 -0
  110. package/src/match/voice/assets/score-deu-90.wav +0 -0
  111. package/src/match/voice/assets/score-deu-lam.wav +0 -0
  112. package/src/match/voice/assets/score-deu-mot.wav +0 -0
  113. package/src/match/voice/assets/server-1.wav +0 -0
  114. package/src/match/voice/assets/server-2.wav +0 -0
  115. package/src/match/voice/assets/service-change.wav +0 -0
  116. package/src/match/voice/assets/test-speaker.wav +0 -0
  117. package/src/match/voice/audio.ts +231 -0
  118. package/src/match/voice/bundled-assets.native.ts +47 -0
  119. package/src/match/voice/bundled-assets.ts +3 -0
  120. package/src/match/voice/gemini-engine.ts +167 -0
  121. package/src/match/voice/index.ts +7 -0
  122. package/src/match/voice/phrases.ts +185 -0
  123. package/src/match/voice/scheduler.ts +205 -0
  124. package/src/match/voice/service.ts +147 -0
  125. package/src/match/voice/types.ts +118 -0
  126. package/src/native-runtime.ts +241 -0
  127. package/src/plugin.ts +145 -0
  128. package/src/provider.tsx +176 -0
  129. package/src/version.ts +4 -0
@@ -0,0 +1,20 @@
1
+ import { createExpoSqliteMatchDriver } from "./sqlite-driver";
2
+ import { MatchEventStore } from "./store";
3
+
4
+ const defaultStores = new Map<string, Promise<MatchEventStore>>();
5
+
6
+ export function createDefaultMatchEventStore(
7
+ databaseName = "pickleball-matches.db",
8
+ ): Promise<MatchEventStore> {
9
+ const existing = defaultStores.get(databaseName);
10
+ if (existing) return existing;
11
+ const pending = import("expo-sqlite").then(async ({ openDatabaseAsync }) => {
12
+ const database = await openDatabaseAsync(databaseName, { useNewConnection: true });
13
+ return new MatchEventStore(createExpoSqliteMatchDriver(database));
14
+ });
15
+ defaultStores.set(databaseName, pending);
16
+ void pending.catch(() => {
17
+ if (defaultStores.get(databaseName) === pending) defaultStores.delete(databaseName);
18
+ });
19
+ return pending;
20
+ }
@@ -0,0 +1,11 @@
1
+ import { MatchEventStore } from "./store";
2
+ import { MatchPersistenceError } from "./types";
3
+
4
+ export async function createDefaultMatchEventStore(
5
+ _databaseName = "pickleball-matches.db",
6
+ ): Promise<MatchEventStore> {
7
+ throw new MatchPersistenceError(
8
+ "storage_failure",
9
+ "The default match store requires a native iOS or Android Expo runtime",
10
+ );
11
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types";
2
+ export * from "./migrations";
3
+ export * from "./memory-driver";
4
+ export * from "./sqlite-driver";
5
+ export * from "./store";
6
+ export * from "./default-store";
@@ -0,0 +1,255 @@
1
+ import type {
2
+ MatchPersistenceDriver,
3
+ MatchPersistenceTransaction,
4
+ StoredMatchEventRow,
5
+ StoredMatchRow,
6
+ } from "./types";
7
+ import type { RemoteBinding } from "../remote/types";
8
+
9
+ interface MemoryState {
10
+ migrationVersion: number;
11
+ remoteBinding: RemoteBinding | null;
12
+ activeMatchId: string | null;
13
+ matches: StoredMatchRow[];
14
+ events: StoredMatchEventRow[];
15
+ nextOrdinal: number;
16
+ ordinals: Record<string, number>;
17
+ }
18
+
19
+ function cloneState(state: MemoryState): MemoryState {
20
+ return {
21
+ migrationVersion: state.migrationVersion,
22
+ remoteBinding: state.remoteBinding ? { ...state.remoteBinding } : null,
23
+ activeMatchId: state.activeMatchId,
24
+ matches: state.matches.map((row) => ({ ...row })),
25
+ events: state.events.map((row) => ({ ...row })),
26
+ nextOrdinal: state.nextOrdinal,
27
+ ordinals: { ...state.ordinals },
28
+ };
29
+ }
30
+
31
+ export class MemoryMatchPersistenceDriver implements MatchPersistenceDriver {
32
+ private state: MemoryState = {
33
+ migrationVersion: 0,
34
+ remoteBinding: null,
35
+ activeMatchId: null,
36
+ matches: [],
37
+ events: [],
38
+ nextOrdinal: 0,
39
+ ordinals: {},
40
+ };
41
+ private eventInsertFailure: Error | null = null;
42
+ private snapshotUpdateFailure: Error | null = null;
43
+ private transactionTail: Promise<void> = Promise.resolve();
44
+
45
+ async migrate() {
46
+ this.state.migrationVersion = 2;
47
+ }
48
+
49
+ async getRemoteBinding() {
50
+ return this.state.remoteBinding ? { ...this.state.remoteBinding } : null;
51
+ }
52
+
53
+ async setRemoteBinding(binding: RemoteBinding | null) {
54
+ this.state.remoteBinding = binding ? { ...binding } : null;
55
+ }
56
+
57
+ getMigrationVersion() {
58
+ return this.state.migrationVersion;
59
+ }
60
+
61
+ async transaction<T>(
62
+ work: (transaction: MatchPersistenceTransaction) => Promise<T>,
63
+ ): Promise<T> {
64
+ const previous = this.transactionTail;
65
+ let release!: () => void;
66
+ this.transactionTail = new Promise<void>((resolve) => {
67
+ release = resolve;
68
+ });
69
+ await previous;
70
+ try {
71
+ const draft = cloneState(this.state);
72
+ const transaction: MatchPersistenceTransaction = {
73
+ getMatch: async (matchId) => {
74
+ const row = draft.matches.find(
75
+ (candidate) => candidate.matchId === matchId,
76
+ );
77
+ return row ? { ...row } : null;
78
+ },
79
+ getEvents: async (matchId) =>
80
+ draft.events
81
+ .filter((row) => row.matchId === matchId)
82
+ .sort((left, right) => left.sequence - right.sequence)
83
+ .map((row) => ({ ...row })),
84
+ insertMatch: async (row) => {
85
+ if (draft.matches.some((candidate) => candidate.matchId === row.matchId)) {
86
+ throw new Error("match id already exists");
87
+ }
88
+ draft.matches.push({ ...row });
89
+ draft.ordinals[row.matchId] = draft.nextOrdinal;
90
+ draft.nextOrdinal += 1;
91
+ },
92
+ insertEvent: async (row) => {
93
+ if (this.eventInsertFailure) {
94
+ const failure = this.eventInsertFailure;
95
+ this.eventInsertFailure = null;
96
+ throw failure;
97
+ }
98
+ if (
99
+ draft.events.some(
100
+ (candidate) =>
101
+ candidate.eventId === row.eventId ||
102
+ (candidate.matchId === row.matchId && candidate.sequence === row.sequence),
103
+ )
104
+ ) {
105
+ throw new Error("event ordering constraint failed");
106
+ }
107
+ if (!draft.matches.some((candidate) => candidate.matchId === row.matchId)) {
108
+ throw new Error("event foreign key constraint failed");
109
+ }
110
+ draft.events.push({ ...row });
111
+ },
112
+ updateSnapshot: async (matchId, expectedSequence, row) => {
113
+ if (this.snapshotUpdateFailure) {
114
+ const failure = this.snapshotUpdateFailure;
115
+ this.snapshotUpdateFailure = null;
116
+ throw failure;
117
+ }
118
+ const index = draft.matches.findIndex(
119
+ (candidate) =>
120
+ candidate.matchId === matchId &&
121
+ candidate.snapshotSequence === expectedSequence,
122
+ );
123
+ if (index < 0) return false;
124
+ draft.matches[index] = { matchId, ...row };
125
+ return true;
126
+ },
127
+ setActiveMatch: async (matchId) => {
128
+ if (
129
+ matchId !== null &&
130
+ !draft.matches.some((candidate) => candidate.matchId === matchId)
131
+ ) {
132
+ throw new Error("active match foreign key constraint failed");
133
+ }
134
+ draft.activeMatchId = matchId;
135
+ },
136
+ clearActiveMatch: async (matchId) => {
137
+ if (draft.activeMatchId === matchId) draft.activeMatchId = null;
138
+ },
139
+ };
140
+ const result = await work(transaction);
141
+ this.state = draft;
142
+ return result;
143
+ } finally {
144
+ release();
145
+ }
146
+ }
147
+
148
+ async readMatch(matchId: string) {
149
+ const snapshot = cloneState(this.state);
150
+ return {
151
+ match: snapshot.matches.find((candidate) => candidate.matchId === matchId) ?? null,
152
+ events: snapshot.events
153
+ .filter((row) => row.matchId === matchId)
154
+ .sort((left, right) => left.sequence - right.sequence),
155
+ };
156
+ }
157
+
158
+ async getActiveMatchId() {
159
+ return this.state.activeMatchId;
160
+ }
161
+
162
+ async readActiveMatch() {
163
+ const snapshot = cloneState(this.state);
164
+ const activeMatchId = snapshot.activeMatchId;
165
+ if (!activeMatchId) {
166
+ return { activeMatchId: null, match: null, events: [] };
167
+ }
168
+ return {
169
+ activeMatchId,
170
+ match:
171
+ snapshot.matches.find((candidate) => candidate.matchId === activeMatchId) ??
172
+ null,
173
+ events: snapshot.events
174
+ .filter((row) => row.matchId === activeMatchId)
175
+ .sort((left, right) => left.sequence - right.sequence),
176
+ };
177
+ }
178
+
179
+ async readMatchList() {
180
+ const snapshot = cloneState(this.state);
181
+ return {
182
+ matches: snapshot.matches
183
+ .slice()
184
+ .sort(
185
+ (left, right) =>
186
+ right.updatedAt - left.updatedAt ||
187
+ snapshot.ordinals[right.matchId] - snapshot.ordinals[left.matchId],
188
+ ),
189
+ activeMatchId: snapshot.activeMatchId,
190
+ };
191
+ }
192
+
193
+ inspect() {
194
+ return cloneState(this.state);
195
+ }
196
+
197
+ failNextSnapshotUpdate(error: Error) {
198
+ this.snapshotUpdateFailure = error;
199
+ }
200
+
201
+ failNextEventInsert(error: Error) {
202
+ this.eventInsertFailure = error;
203
+ }
204
+
205
+ corruptSnapshotJson(matchId: string, snapshotJson: string) {
206
+ this.mutateMatch(matchId, (row) => ({ ...row, snapshotJson }));
207
+ }
208
+
209
+ corruptEventJson(matchId: string, sequence: number, eventJson: string) {
210
+ this.mutateEvent(matchId, sequence, (row) => ({ ...row, eventJson }));
211
+ }
212
+
213
+ corruptEventSchema(matchId: string, sequence: number, schemaVersion: number) {
214
+ this.mutateEvent(matchId, sequence, (row) => ({ ...row, schemaVersion }));
215
+ }
216
+
217
+ corruptEventSequence(matchId: string, sequence: number) {
218
+ this.mutateEvent(matchId, 0, (row) => ({ ...row, sequence }));
219
+ }
220
+
221
+ duplicateEventRow(matchId: string, sequence: number) {
222
+ const row = this.state.events.find(
223
+ (candidate) => candidate.matchId === matchId && candidate.sequence === sequence,
224
+ );
225
+ if (!row) throw new Error("event row not found");
226
+ this.state.events.push({ ...row });
227
+ }
228
+
229
+ removeEventRow(matchId: string, sequence: number) {
230
+ this.state.events = this.state.events.filter(
231
+ (row) => row.matchId !== matchId || row.sequence !== sequence,
232
+ );
233
+ }
234
+
235
+ private mutateMatch(
236
+ matchId: string,
237
+ mutation: (row: StoredMatchRow) => StoredMatchRow,
238
+ ) {
239
+ const index = this.state.matches.findIndex((row) => row.matchId === matchId);
240
+ if (index < 0) throw new Error("match row not found");
241
+ this.state.matches[index] = mutation(this.state.matches[index]);
242
+ }
243
+
244
+ private mutateEvent(
245
+ matchId: string,
246
+ sequence: number,
247
+ mutation: (row: StoredMatchEventRow) => StoredMatchEventRow,
248
+ ) {
249
+ const index = this.state.events.findIndex(
250
+ (row) => row.matchId === matchId && row.sequence === sequence,
251
+ );
252
+ if (index < 0) throw new Error("event row not found");
253
+ this.state.events[index] = mutation(this.state.events[index]);
254
+ }
255
+ }
@@ -0,0 +1,40 @@
1
+ export const MATCH_SQL_SCHEMA_VERSION = 2 as const;
2
+
3
+ export const MATCH_SQL_MIGRATIONS = [
4
+ `CREATE TABLE IF NOT EXISTS match_aggregates (
5
+ match_id TEXT PRIMARY KEY NOT NULL,
6
+ schema_version INTEGER NOT NULL CHECK (schema_version = 1),
7
+ snapshot_json TEXT NOT NULL,
8
+ snapshot_sequence INTEGER NOT NULL CHECK (snapshot_sequence >= 0),
9
+ status TEXT NOT NULL CHECK (status IN ('inProgress', 'completed', 'ended')),
10
+ updated_at INTEGER NOT NULL
11
+ )`,
12
+ `CREATE TABLE IF NOT EXISTS match_events (
13
+ match_id TEXT NOT NULL,
14
+ sequence INTEGER NOT NULL CHECK (sequence >= 0),
15
+ event_id TEXT NOT NULL UNIQUE,
16
+ schema_version INTEGER NOT NULL CHECK (schema_version = 1),
17
+ event_json TEXT NOT NULL,
18
+ created_at INTEGER NOT NULL,
19
+ PRIMARY KEY (match_id, sequence),
20
+ FOREIGN KEY (match_id) REFERENCES match_aggregates(match_id) ON DELETE RESTRICT
21
+ )`,
22
+ `CREATE TABLE IF NOT EXISTS match_active_pointer (
23
+ slot INTEGER PRIMARY KEY NOT NULL CHECK (slot = 1),
24
+ match_id TEXT NOT NULL UNIQUE,
25
+ FOREIGN KEY (match_id) REFERENCES match_aggregates(match_id) ON DELETE RESTRICT
26
+ )`,
27
+ `CREATE INDEX IF NOT EXISTS match_aggregates_recent
28
+ ON match_aggregates(updated_at DESC)`,
29
+ `CREATE TRIGGER IF NOT EXISTS match_events_no_update
30
+ BEFORE UPDATE ON match_events
31
+ BEGIN SELECT RAISE(ABORT, 'match events are immutable'); END`,
32
+ `CREATE TRIGGER IF NOT EXISTS match_events_no_delete
33
+ BEFORE DELETE ON match_events
34
+ BEGIN SELECT RAISE(ABORT, 'match events are immutable'); END`,
35
+ `CREATE TABLE IF NOT EXISTS remote_binding (
36
+ slot INTEGER PRIMARY KEY NOT NULL CHECK (slot = 1),
37
+ device_id TEXT NOT NULL CHECK (length(trim(device_id)) > 0),
38
+ label TEXT
39
+ )`,
40
+ ] as const;
@@ -0,0 +1,348 @@
1
+ import { MATCH_SQL_MIGRATIONS, MATCH_SQL_SCHEMA_VERSION } from "./migrations";
2
+ import {
3
+ MatchPersistenceError,
4
+ type MatchPersistenceDriver,
5
+ type MatchPersistenceTransaction,
6
+ type StoredMatchEventRow,
7
+ type StoredMatchRow,
8
+ } from "./types";
9
+ import type { RemoteBinding } from "../remote/types";
10
+ /*
11
+ * Coordination is shared for every adapter wrapping the same Expo SQLite
12
+ * connection object. Distinct connections to the same file are coordinated by
13
+ * SQLite itself; busy_timeout bounds their lock wait instead of a process-global
14
+ * name registry that would retain database names indefinitely.
15
+ */
16
+ interface ConnectionCoordination {
17
+ tail: Promise<void>;
18
+ poisoned: MatchPersistenceError | null;
19
+ }
20
+
21
+ const connectionCoordinators = new WeakMap<object, ConnectionCoordination>();
22
+
23
+ function coordinationFor(database: ExpoSqliteDatabaseLike): ConnectionCoordination {
24
+ const existing = connectionCoordinators.get(database);
25
+ if (existing) return existing;
26
+ const coordination: ConnectionCoordination = {
27
+ tail: Promise.resolve(),
28
+ poisoned: null,
29
+ };
30
+ connectionCoordinators.set(database, coordination);
31
+ return coordination;
32
+ }
33
+
34
+ interface SqlRunResult {
35
+ readonly changes: number;
36
+ readonly lastInsertRowId: number;
37
+ }
38
+
39
+ interface SqlExecutor {
40
+ runAsync(sql: string, ...params: unknown[]): Promise<SqlRunResult>;
41
+ getFirstAsync<T>(sql: string, ...params: unknown[]): Promise<T | null>;
42
+ getAllAsync<T>(sql: string, ...params: unknown[]): Promise<T[]>;
43
+ }
44
+
45
+ export interface ExpoSqliteDatabaseLike extends SqlExecutor {
46
+ execAsync(sql: string): Promise<void>;
47
+ }
48
+
49
+ interface SqlMatchRow {
50
+ match_id: string;
51
+ schema_version: number;
52
+ snapshot_json: string;
53
+ snapshot_sequence: number;
54
+ status: StoredMatchRow["status"];
55
+ updated_at: number;
56
+ }
57
+
58
+ interface SqlEventRow {
59
+ match_id: string;
60
+ sequence: number;
61
+ event_id: string;
62
+ schema_version: number;
63
+ event_json: string;
64
+ created_at: number;
65
+ }
66
+
67
+ const mapMatchRow = (row: SqlMatchRow): StoredMatchRow => ({
68
+ matchId: row.match_id,
69
+ schemaVersion: row.schema_version,
70
+ snapshotJson: row.snapshot_json,
71
+ snapshotSequence: row.snapshot_sequence,
72
+ status: row.status,
73
+ updatedAt: row.updated_at,
74
+ });
75
+
76
+ const mapEventRow = (row: SqlEventRow): StoredMatchEventRow => ({
77
+ matchId: row.match_id,
78
+ sequence: row.sequence,
79
+ eventId: row.event_id,
80
+ schemaVersion: row.schema_version,
81
+ eventJson: row.event_json,
82
+ createdAt: row.created_at,
83
+ });
84
+
85
+ function transactionFor(executor: SqlExecutor): MatchPersistenceTransaction {
86
+ return {
87
+ async getMatch(matchId) {
88
+ const row = await executor.getFirstAsync<SqlMatchRow>(
89
+ "SELECT * FROM match_aggregates WHERE match_id = ?",
90
+ matchId,
91
+ );
92
+ return row ? mapMatchRow(row) : null;
93
+ },
94
+ async getEvents(matchId) {
95
+ const rows = await executor.getAllAsync<SqlEventRow>(
96
+ "SELECT * FROM match_events WHERE match_id = ? ORDER BY sequence ASC",
97
+ matchId,
98
+ );
99
+ return rows.map(mapEventRow);
100
+ },
101
+ async insertMatch(row) {
102
+ await executor.runAsync(
103
+ `INSERT INTO match_aggregates
104
+ (match_id, schema_version, snapshot_json, snapshot_sequence, status, updated_at)
105
+ VALUES (?, ?, ?, ?, ?, ?)`,
106
+ row.matchId,
107
+ row.schemaVersion,
108
+ row.snapshotJson,
109
+ row.snapshotSequence,
110
+ row.status,
111
+ row.updatedAt,
112
+ );
113
+ },
114
+ async insertEvent(row) {
115
+ await executor.runAsync(
116
+ `INSERT INTO match_events
117
+ (match_id, sequence, event_id, schema_version, event_json, created_at)
118
+ VALUES (?, ?, ?, ?, ?, ?)`,
119
+ row.matchId,
120
+ row.sequence,
121
+ row.eventId,
122
+ row.schemaVersion,
123
+ row.eventJson,
124
+ row.createdAt,
125
+ );
126
+ },
127
+ async updateSnapshot(matchId, expectedSequence, row) {
128
+ const result = await executor.runAsync(
129
+ `UPDATE match_aggregates
130
+ SET schema_version = ?, snapshot_json = ?, snapshot_sequence = ?, status = ?, updated_at = ?
131
+ WHERE match_id = ? AND snapshot_sequence = ?`,
132
+ row.schemaVersion,
133
+ row.snapshotJson,
134
+ row.snapshotSequence,
135
+ row.status,
136
+ row.updatedAt,
137
+ matchId,
138
+ expectedSequence,
139
+ );
140
+ return result.changes === 1;
141
+ },
142
+ async setActiveMatch(matchId) {
143
+ if (matchId === null) {
144
+ await executor.runAsync("DELETE FROM match_active_pointer WHERE slot = 1");
145
+ return;
146
+ }
147
+ await executor.runAsync(
148
+ `INSERT INTO match_active_pointer (slot, match_id) VALUES (1, ?)
149
+ ON CONFLICT(slot) DO UPDATE SET match_id = excluded.match_id`,
150
+ matchId,
151
+ );
152
+ },
153
+ async clearActiveMatch(matchId) {
154
+ await executor.runAsync(
155
+ "DELETE FROM match_active_pointer WHERE slot = 1 AND match_id = ?",
156
+ matchId,
157
+ );
158
+ },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Serializes adapters sharing this exact connection object. Separate
164
+ * connections to the same file rely on SQLite locking plus busy_timeout.
165
+ */
166
+ export function createExpoSqliteMatchDriver(
167
+ database: ExpoSqliteDatabaseLike,
168
+ ): MatchPersistenceDriver {
169
+ const coordination = coordinationFor(database);
170
+
171
+ function poisonAfterRollbackFailure(error: unknown) {
172
+ if (!coordination.poisoned) {
173
+ coordination.poisoned = new MatchPersistenceError(
174
+ "storage_failure",
175
+ "The SQLite connection is unusable after rollback failed",
176
+ { cause: error },
177
+ );
178
+ }
179
+ }
180
+
181
+ async function runSerialized<T>(work: () => Promise<T>): Promise<T> {
182
+ const previous = coordination.tail;
183
+ let release!: () => void;
184
+ coordination.tail = new Promise<void>((resolve) => {
185
+ release = resolve;
186
+ });
187
+ await previous;
188
+ try {
189
+ if (coordination.poisoned) throw coordination.poisoned;
190
+ return await work();
191
+ } finally {
192
+ release();
193
+ }
194
+ }
195
+
196
+ function runAtomic<T>(
197
+ begin: "BEGIN" | "BEGIN IMMEDIATE",
198
+ work: (transaction: MatchPersistenceTransaction) => Promise<T>,
199
+ ): Promise<T> {
200
+ return runSerialized(async () => {
201
+ let began = false;
202
+ try {
203
+ await database.execAsync(begin);
204
+ began = true;
205
+ const result = await work(transactionFor(database));
206
+ await database.execAsync("COMMIT");
207
+ return result;
208
+ } catch (error) {
209
+ if (!began) throw error;
210
+ try {
211
+ await database.execAsync("ROLLBACK");
212
+ } catch (rollbackError) {
213
+ poisonAfterRollbackFailure(rollbackError);
214
+ }
215
+ throw error;
216
+ }
217
+ });
218
+ }
219
+
220
+ return {
221
+ async migrate() {
222
+ await runSerialized(async () => {
223
+ await database.execAsync("PRAGMA busy_timeout = 5000");
224
+ await database.execAsync("PRAGMA foreign_keys = ON");
225
+ const foreignKeys = await database.getFirstAsync<{ foreign_keys: number }>(
226
+ "PRAGMA foreign_keys",
227
+ );
228
+ if (foreignKeys?.foreign_keys !== 1) {
229
+ throw new MatchPersistenceError(
230
+ "storage_failure",
231
+ "SQLite foreign-key enforcement could not be enabled",
232
+ );
233
+ }
234
+ const version = await database.getFirstAsync<{ user_version: number }>(
235
+ "PRAGMA user_version",
236
+ );
237
+ if (!version || !Number.isInteger(version.user_version)) {
238
+ throw new MatchPersistenceError(
239
+ "storage_failure",
240
+ "SQLite did not return a valid schema version",
241
+ );
242
+ }
243
+ if (version.user_version > MATCH_SQL_SCHEMA_VERSION) {
244
+ throw new MatchPersistenceError(
245
+ "unsupported_schema",
246
+ "The local match database uses a newer schema version",
247
+ );
248
+ }
249
+ if (version.user_version === MATCH_SQL_SCHEMA_VERSION) return;
250
+
251
+ let began = false;
252
+ try {
253
+ await database.execAsync("BEGIN IMMEDIATE");
254
+ began = true;
255
+ for (const migration of MATCH_SQL_MIGRATIONS) {
256
+ await database.execAsync(migration);
257
+ }
258
+ await database.execAsync(`PRAGMA user_version = ${MATCH_SQL_SCHEMA_VERSION}`);
259
+ await database.execAsync("COMMIT");
260
+ } catch (error) {
261
+ if (began) {
262
+ try {
263
+ await database.execAsync("ROLLBACK");
264
+ } catch (rollbackError) {
265
+ poisonAfterRollbackFailure(rollbackError);
266
+ }
267
+ }
268
+ throw error;
269
+ }
270
+ });
271
+ },
272
+ async transaction<T>(work: (transaction: MatchPersistenceTransaction) => Promise<T>) {
273
+ return runAtomic("BEGIN IMMEDIATE", work);
274
+ },
275
+ async readMatch(matchId) {
276
+ return runAtomic("BEGIN", async (reader) => {
277
+ const [match, events] = await Promise.all([
278
+ reader.getMatch(matchId),
279
+ reader.getEvents(matchId),
280
+ ]);
281
+ return { match, events };
282
+ });
283
+ },
284
+ async getActiveMatchId() {
285
+ return runSerialized(async () => {
286
+ const row = await database.getFirstAsync<{ match_id: string }>(
287
+ "SELECT match_id FROM match_active_pointer WHERE slot = 1",
288
+ );
289
+ return row?.match_id ?? null;
290
+ });
291
+ },
292
+ async readActiveMatch() {
293
+ return runAtomic("BEGIN", async (reader) => {
294
+ const active = await database.getFirstAsync<{ match_id: string }>(
295
+ "SELECT match_id FROM match_active_pointer WHERE slot = 1",
296
+ );
297
+ if (!active) {
298
+ return { activeMatchId: null, match: null, events: [] };
299
+ }
300
+ const [match, events] = await Promise.all([
301
+ reader.getMatch(active.match_id),
302
+ reader.getEvents(active.match_id),
303
+ ]);
304
+ return { activeMatchId: active.match_id, match, events };
305
+ });
306
+ },
307
+ async readMatchList() {
308
+ return runAtomic("BEGIN", async () => {
309
+ const [matches, activeMatch] = await Promise.all([
310
+ database.getAllAsync<SqlMatchRow>(
311
+ "SELECT * FROM match_aggregates ORDER BY updated_at DESC, rowid DESC",
312
+ ),
313
+ database.getFirstAsync<{ match_id: string }>(
314
+ "SELECT match_id FROM match_active_pointer WHERE slot = 1",
315
+ ),
316
+ ]);
317
+ return {
318
+ matches: matches.map(mapMatchRow),
319
+ activeMatchId: activeMatch?.match_id ?? null,
320
+ };
321
+ });
322
+ },
323
+ async getRemoteBinding() {
324
+ return runSerialized(async () => {
325
+ const row = await database.getFirstAsync<{ device_id: string; label: string | null }>(
326
+ "SELECT device_id, label FROM remote_binding WHERE slot = 1",
327
+ );
328
+ return row
329
+ ? { deviceId: row.device_id, ...(row.label === null ? {} : { label: row.label }) }
330
+ : null;
331
+ });
332
+ },
333
+ async setRemoteBinding(binding: RemoteBinding | null) {
334
+ return runSerialized(async () => {
335
+ if (!binding) {
336
+ await database.runAsync("DELETE FROM remote_binding WHERE slot = 1");
337
+ return;
338
+ }
339
+ await database.runAsync(
340
+ `INSERT INTO remote_binding (slot, device_id, label) VALUES (1, ?, ?)
341
+ ON CONFLICT(slot) DO UPDATE SET device_id = excluded.device_id, label = excluded.label`,
342
+ binding.deviceId,
343
+ binding.label ?? null,
344
+ );
345
+ });
346
+ },
347
+ };
348
+ }