@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,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,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
|
+
}
|