@chaos-overlords/storage 0.8.1 → 0.9.0
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/dist/node/index.d.ts +11 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +26 -7
- package/dist/node/index.js.map +1 -1
- package/dist/postgres/database.d.ts +10 -0
- package/dist/postgres/database.d.ts.map +1 -0
- package/dist/postgres/database.js +2 -0
- package/dist/postgres/database.js.map +1 -0
- package/dist/postgres/events.d.ts +9 -0
- package/dist/postgres/events.d.ts.map +1 -0
- package/dist/postgres/events.js +56 -0
- package/dist/postgres/events.js.map +1 -0
- package/dist/postgres/repositories.d.ts +2 -3
- package/dist/postgres/repositories.d.ts.map +1 -1
- package/dist/postgres/repositories.js +188 -237
- package/dist/postgres/repositories.js.map +1 -1
- package/dist/postgres/schema.d.ts +26 -9
- package/dist/postgres/schema.d.ts.map +1 -1
- package/dist/postgres/schema.js +6 -0
- package/dist/postgres/schema.js.map +1 -1
- package/dist/postgres/takeovers.d.ts +12 -0
- package/dist/postgres/takeovers.d.ts.map +1 -0
- package/dist/postgres/takeovers.js +88 -0
- package/dist/postgres/takeovers.js.map +1 -0
- package/dist/shared/constraints.d.ts +18 -0
- package/dist/shared/constraints.d.ts.map +1 -1
- package/dist/shared/constraints.js +36 -0
- package/dist/shared/constraints.js.map +1 -1
- package/dist/shared/mappers.d.ts +28 -5
- package/dist/shared/mappers.d.ts.map +1 -1
- package/dist/shared/mappers.js +31 -0
- package/dist/shared/mappers.js.map +1 -1
- package/dist/sqlite/database.d.ts +12 -0
- package/dist/sqlite/database.d.ts.map +1 -0
- package/dist/sqlite/database.js +2 -0
- package/dist/sqlite/database.js.map +1 -0
- package/dist/sqlite/events.d.ts +9 -0
- package/dist/sqlite/events.d.ts.map +1 -0
- package/dist/sqlite/events.js +56 -0
- package/dist/sqlite/events.js.map +1 -0
- package/dist/sqlite/repositories.d.ts +2 -8
- package/dist/sqlite/repositories.d.ts.map +1 -1
- package/dist/sqlite/repositories.js +214 -237
- package/dist/sqlite/repositories.js.map +1 -1
- package/dist/sqlite/schema.d.ts +26 -9
- package/dist/sqlite/schema.d.ts.map +1 -1
- package/dist/sqlite/schema.js +6 -0
- package/dist/sqlite/schema.js.map +1 -1
- package/dist/sqlite/takeovers.d.ts +12 -0
- package/dist/sqlite/takeovers.d.ts.map +1 -0
- package/dist/sqlite/takeovers.js +88 -0
- package/dist/sqlite/takeovers.js.map +1 -0
- package/migrations/postgres/0005_old_husk.sql +6 -0
- package/migrations/postgres/meta/0005_snapshot.json +854 -0
- package/migrations/postgres/meta/_journal.json +8 -1
- package/migrations/sqlite/0005_lumpy_stingray.sql +6 -0
- package/migrations/sqlite/meta/0005_snapshot.json +844 -0
- package/migrations/sqlite/meta/_journal.json +8 -1
- package/package.json +7 -7
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { and, asc, desc, eq, exists, inArray, isNotNull, isNull, lt, lte, ne, notExists, or, sql, } from 'drizzle-orm';
|
|
2
|
-
import {
|
|
3
|
-
import { firstOrNull,
|
|
1
|
+
import { and, asc, desc, eq, exists, gte, inArray, isNotNull, isNull, lt, lte, ne, notExists, or, sql, } from 'drizzle-orm';
|
|
2
|
+
import { insertUnlessTaken } from '../shared/constraints';
|
|
3
|
+
import { firstOrNull, toMatch, toMatchInsert, toPlayer, toPublicLobbyRow, toSnapshot, toSnapshotSummary, toTurn, toTurnOrders, toTurnReport, } from '../shared/mappers';
|
|
4
|
+
import { sqliteEventRepository } from './events';
|
|
4
5
|
import * as schema from './schema';
|
|
6
|
+
import { sqliteTakeoverRepository } from './takeovers';
|
|
5
7
|
export function createSqliteStorage(db) {
|
|
6
8
|
return {
|
|
7
9
|
matches: sqliteMatchRepository(db),
|
|
@@ -13,7 +15,15 @@ export function createSqliteStorage(db) {
|
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
function sqliteMatchRepository(db) {
|
|
16
|
-
const { matches, players } = schema;
|
|
18
|
+
const { matches, players, snapshots } = schema;
|
|
19
|
+
// Children cascade from the match row, so one delete takes the whole match with it.
|
|
20
|
+
const deleteCollectable = async (collectable) => {
|
|
21
|
+
const rows = await db
|
|
22
|
+
.delete(matches)
|
|
23
|
+
.where(inArray(matches.id, collectable))
|
|
24
|
+
.returning({ id: matches.id });
|
|
25
|
+
return rows.length;
|
|
26
|
+
};
|
|
17
27
|
return {
|
|
18
28
|
async create(match) {
|
|
19
29
|
return insertUnlessTaken(() => db.insert(matches).values(toMatchInsert(match)));
|
|
@@ -24,34 +34,45 @@ function sqliteMatchRepository(db) {
|
|
|
24
34
|
async getByJoinCode(joinCode) {
|
|
25
35
|
return firstOrNull((await db.select().from(matches).where(eq(matches.joinCode, joinCode))).map(toMatch));
|
|
26
36
|
},
|
|
37
|
+
/**
|
|
38
|
+
* One statement for the whole listing. The human-seat count and the existence of a snapshot
|
|
39
|
+
* are correlated subselects rather than a read per listed match: the route is unauthenticated,
|
|
40
|
+
* and two extra queries per running match is the cost an anonymous caller could ask for
|
|
41
|
+
* thirty times a minute. A running match no human holds a seat in is left out, which is the
|
|
42
|
+
* same test `joinRunning` refuses on: a match everyone has left is paused, so joining one is
|
|
43
|
+
* a seat at a table whose clock is stopped.
|
|
44
|
+
*
|
|
45
|
+
* A seat counts as human while it is `takeoverPending` as well as while it is `active`. That
|
|
46
|
+
* status is a player who missed one deadline, not one who is gone — `humanParticipants` waits
|
|
47
|
+
* on them and the takeover vote is what decides otherwise — so counting only `active` hid
|
|
48
|
+
* live matches from the listing and understated the players in the ones it kept.
|
|
49
|
+
*/
|
|
27
50
|
async listPublicLobbies(limit) {
|
|
51
|
+
const humanSeats = sql `(select count(*) from ${players} where ${players.matchId} = ${matches.id} and ${players.status} in ('active', 'takeoverPending'))`;
|
|
28
52
|
const rows = await db
|
|
29
53
|
.select({
|
|
30
54
|
id: matches.id,
|
|
31
55
|
joinCode: matches.joinCode,
|
|
32
56
|
name: matches.name,
|
|
33
57
|
hostDisplayName: players.displayName,
|
|
34
|
-
|
|
58
|
+
seatCount: matches.seatCount,
|
|
59
|
+
humanCount: humanSeats,
|
|
35
60
|
maxPlayers: matches.maxPlayers,
|
|
36
61
|
passwordHash: matches.passwordHash,
|
|
37
62
|
status: matches.status,
|
|
38
63
|
settings: matches.settings,
|
|
39
64
|
createdAt: matches.createdAt,
|
|
65
|
+
hasSnapshot: exists(db
|
|
66
|
+
.select({ one: sql `1` })
|
|
67
|
+
.from(snapshots)
|
|
68
|
+
.where(eq(snapshots.matchId, matches.id))),
|
|
40
69
|
})
|
|
41
70
|
.from(matches)
|
|
42
71
|
.innerJoin(players, eq(players.id, matches.hostPlayerId))
|
|
43
|
-
.where(and(inArray(matches.status, ['lobby', 'running']), eq(matches.visibility, 'public')))
|
|
72
|
+
.where(and(inArray(matches.status, ['lobby', 'running']), eq(matches.visibility, 'public'), or(ne(matches.status, 'running'), sql `${humanSeats} > 0`)))
|
|
44
73
|
.orderBy(desc(matches.createdAt), asc(matches.id))
|
|
45
74
|
.limit(limit);
|
|
46
|
-
return rows.map(
|
|
47
|
-
...rest,
|
|
48
|
-
status: rest.status,
|
|
49
|
-
settings: rest.settings,
|
|
50
|
-
availableSlots: [],
|
|
51
|
-
availableSeatSummaries: [],
|
|
52
|
-
passwordProtected: passwordHash !== null,
|
|
53
|
-
createdAt: createdAt.toISOString(),
|
|
54
|
-
}));
|
|
75
|
+
return rows.map(toPublicLobbyRow);
|
|
55
76
|
},
|
|
56
77
|
async claimSeat(matchId) {
|
|
57
78
|
const rows = await db
|
|
@@ -102,32 +123,33 @@ function sqliteMatchRepository(db) {
|
|
|
102
123
|
.from(matches)
|
|
103
124
|
.where(and(inArray(matches.status, [...statuses]), lt(matches.updatedAt, before)))
|
|
104
125
|
.limit(limit);
|
|
105
|
-
|
|
106
|
-
const rows = await db
|
|
107
|
-
.delete(matches)
|
|
108
|
-
.where(inArray(matches.id, collectable))
|
|
109
|
-
.returning({ id: matches.id });
|
|
110
|
-
return rows.length;
|
|
126
|
+
return deleteCollectable(collectable);
|
|
111
127
|
},
|
|
112
128
|
/**
|
|
113
129
|
* The same delete, aimed at a live match nobody is in any more. The roster test is a
|
|
114
130
|
* `not exists` over the players rather than a count: one active row is enough to spare the
|
|
115
131
|
* match, and asking whether any exists stops at the first.
|
|
116
132
|
*/
|
|
117
|
-
async deleteAbandonedLive(before, limit) {
|
|
133
|
+
async deleteAbandonedLive(before, limit, requireEmptyRoster) {
|
|
134
|
+
const emptyRoster = notExists(db
|
|
135
|
+
.select({ one: sql `1` })
|
|
136
|
+
.from(players)
|
|
137
|
+
.where(and(eq(players.matchId, matches.id), eq(players.status, 'active'))));
|
|
118
138
|
const collectable = db
|
|
119
139
|
.select({ id: matches.id })
|
|
120
140
|
.from(matches)
|
|
121
|
-
.where(and(inArray(matches.status, ['running', 'desynced']), lt(matches.updatedAt, before),
|
|
122
|
-
.select({ one: sql `1` })
|
|
123
|
-
.from(players)
|
|
124
|
-
.where(and(eq(players.matchId, matches.id), eq(players.status, 'active'))))))
|
|
141
|
+
.where(and(inArray(matches.status, ['running', 'desynced']), lt(matches.updatedAt, before), ...(requireEmptyRoster ? [emptyRoster] : [])))
|
|
125
142
|
.limit(limit);
|
|
143
|
+
return deleteCollectable(collectable);
|
|
144
|
+
},
|
|
145
|
+
async listDesynced(limit, touchedSince) {
|
|
126
146
|
const rows = await db
|
|
127
|
-
.
|
|
128
|
-
.
|
|
129
|
-
.
|
|
130
|
-
|
|
147
|
+
.select({ id: matches.id })
|
|
148
|
+
.from(matches)
|
|
149
|
+
.where(and(eq(matches.status, 'desynced'), ...(touchedSince ? [gte(matches.updatedAt, touchedSince)] : [])))
|
|
150
|
+
.orderBy(matches.updatedAt)
|
|
151
|
+
.limit(limit);
|
|
152
|
+
return rows.map((row) => row.id);
|
|
131
153
|
},
|
|
132
154
|
async transition(matchId, from, patch) {
|
|
133
155
|
const rows = await db
|
|
@@ -139,6 +161,28 @@ function sqliteMatchRepository(db) {
|
|
|
139
161
|
},
|
|
140
162
|
};
|
|
141
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* The select list `create` and `createLate` feed their insert from: the player's own values, each
|
|
166
|
+
* under the column name it is inserted as.
|
|
167
|
+
*
|
|
168
|
+
* The list is written out, which means it does NOT get Drizzle's column mapping: a column added to
|
|
169
|
+
* `players` later has to be added here too, in the storage form the column expects. The
|
|
170
|
+
* conformance suite compares a created player against its fixture field by field, so a dropped
|
|
171
|
+
* column fails there rather than going unnoticed.
|
|
172
|
+
*/
|
|
173
|
+
function playerValues(player) {
|
|
174
|
+
return {
|
|
175
|
+
id: sql `${player.id}`.as('id'),
|
|
176
|
+
matchId: sql `${player.matchId}`.as('match_id'),
|
|
177
|
+
slot: sql `${player.slot}`.as('slot'),
|
|
178
|
+
joinOrder: sql `${player.joinOrder}`.as('join_order'),
|
|
179
|
+
displayName: sql `${player.displayName}`.as('display_name'),
|
|
180
|
+
portraitId: sql `${player.portraitId}`.as('portrait_id'),
|
|
181
|
+
tokenHash: sql `${player.tokenHash}`.as('token_hash'),
|
|
182
|
+
status: sql `${player.status}`.as('status'),
|
|
183
|
+
joinedAt: sql `${player.joinedAt.getTime()}`.as('joined_at'),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
142
186
|
function sqlitePlayerRepository(db) {
|
|
143
187
|
const { matches, players } = schema;
|
|
144
188
|
return {
|
|
@@ -147,27 +191,12 @@ function sqlitePlayerRepository(db) {
|
|
|
147
191
|
* by the same statement that writes the player. The seat counter was claimed a moment earlier
|
|
148
192
|
* and the match may have started since; without this the player would land in a running match
|
|
149
193
|
* that had already seated its roster, holding a seat nobody can play.
|
|
150
|
-
*
|
|
151
|
-
* The select list is written out, which means it does NOT get Drizzle's column mapping: a
|
|
152
|
-
* column added to `players` later has to be added here too, in the storage form the column
|
|
153
|
-
* expects. The conformance suite compares a created player against its fixture field by field,
|
|
154
|
-
* so a dropped column fails there rather than going unnoticed.
|
|
155
194
|
*/
|
|
156
195
|
async create(player) {
|
|
157
196
|
const rows = await db
|
|
158
197
|
.insert(players)
|
|
159
198
|
.select(db
|
|
160
|
-
.select(
|
|
161
|
-
id: sql `${player.id}`.as('id'),
|
|
162
|
-
matchId: sql `${player.matchId}`.as('match_id'),
|
|
163
|
-
slot: sql `${player.slot}`.as('slot'),
|
|
164
|
-
joinOrder: sql `${player.joinOrder}`.as('join_order'),
|
|
165
|
-
displayName: sql `${player.displayName}`.as('display_name'),
|
|
166
|
-
portraitId: sql `${player.portraitId}`.as('portrait_id'),
|
|
167
|
-
tokenHash: sql `${player.tokenHash}`.as('token_hash'),
|
|
168
|
-
status: sql `${player.status}`.as('status'),
|
|
169
|
-
joinedAt: sql `${player.joinedAt.getTime()}`.as('joined_at'),
|
|
170
|
-
})
|
|
199
|
+
.select(playerValues(player))
|
|
171
200
|
.from(matches)
|
|
172
201
|
.where(and(eq(matches.id, player.matchId), eq(matches.status, 'lobby'))))
|
|
173
202
|
.returning({ id: players.id });
|
|
@@ -181,19 +210,13 @@ function sqlitePlayerRepository(db) {
|
|
|
181
210
|
const rows = await db
|
|
182
211
|
.insert(players)
|
|
183
212
|
.select(db
|
|
184
|
-
.select(
|
|
185
|
-
id: sql `${player.id}`.as('id'),
|
|
186
|
-
matchId: sql `${player.matchId}`.as('match_id'),
|
|
187
|
-
slot: sql `${player.slot}`.as('slot'),
|
|
188
|
-
joinOrder: sql `${player.joinOrder}`.as('join_order'),
|
|
189
|
-
displayName: sql `${player.displayName}`.as('display_name'),
|
|
190
|
-
portraitId: sql `${player.portraitId}`.as('portrait_id'),
|
|
191
|
-
tokenHash: sql `${player.tokenHash}`.as('token_hash'),
|
|
192
|
-
status: sql `${player.status}`.as('status'),
|
|
193
|
-
joinedAt: sql `${player.joinedAt.getTime()}`.as('joined_at'),
|
|
194
|
-
})
|
|
213
|
+
.select(playerValues(player))
|
|
195
214
|
.from(matches)
|
|
196
|
-
.where(and(eq(matches.id, player.matchId), eq(matches.status, 'running'), notExists(occupied)
|
|
215
|
+
.where(and(eq(matches.id, player.matchId), eq(matches.status, 'running'), notExists(occupied),
|
|
216
|
+
// Capacity in the same statement as the insert. Two late joiners for two different
|
|
217
|
+
// free slots each passed a capacity check taken a moment before the other's insert,
|
|
218
|
+
// and the match ended up holding more players than `maxPlayers`.
|
|
219
|
+
sql `(select count(*) from ${players} where ${players.matchId} = ${player.matchId}) < ${matches.maxPlayers}`)))
|
|
197
220
|
.onConflictDoNothing()
|
|
198
221
|
.returning({ id: players.id });
|
|
199
222
|
return rows.length === 1;
|
|
@@ -212,6 +235,14 @@ function sqlitePlayerRepository(db) {
|
|
|
212
235
|
.orderBy(asc(players.slot), asc(players.joinOrder), asc(players.id));
|
|
213
236
|
return rows.map(toPlayer);
|
|
214
237
|
},
|
|
238
|
+
async listSeats(matchIds) {
|
|
239
|
+
if (matchIds.length === 0)
|
|
240
|
+
return [];
|
|
241
|
+
return db
|
|
242
|
+
.select({ matchId: players.matchId, slot: players.slot })
|
|
243
|
+
.from(players)
|
|
244
|
+
.where(inArray(players.matchId, [...matchIds]));
|
|
245
|
+
},
|
|
215
246
|
async setStatus(playerId, status) {
|
|
216
247
|
await db.update(players).set({ status }).where(eq(players.id, playerId));
|
|
217
248
|
},
|
|
@@ -238,32 +269,29 @@ function sqlitePlayerRepository(db) {
|
|
|
238
269
|
.where(inArray(players.id, assignments.map(({ playerId }) => playerId)));
|
|
239
270
|
},
|
|
240
271
|
async delete(playerId) {
|
|
241
|
-
await db
|
|
272
|
+
const rows = await db
|
|
273
|
+
.delete(players)
|
|
274
|
+
.where(eq(players.id, playerId))
|
|
275
|
+
.returning({ id: players.id });
|
|
276
|
+
return rows.length === 1;
|
|
242
277
|
},
|
|
243
278
|
};
|
|
244
279
|
}
|
|
245
|
-
|
|
246
|
-
|
|
280
|
+
/**
|
|
281
|
+
* The orders half of the turn repository: one player's row, the whole set, and the
|
|
282
|
+
* projection that reads a turn's readiness and digests without the documents. Split out of
|
|
283
|
+
* the repository so neither half grows past what fits on a screen.
|
|
284
|
+
*/
|
|
285
|
+
function sqliteTurnOrderMethods(db) {
|
|
286
|
+
const { turns, turnOrders } = schema;
|
|
287
|
+
const orderSummaryColumns = {
|
|
288
|
+
matchId: turnOrders.matchId,
|
|
289
|
+
turn: turnOrders.turn,
|
|
290
|
+
playerId: turnOrders.playerId,
|
|
291
|
+
ordersHash: turnOrders.ordersHash,
|
|
292
|
+
ready: turnOrders.ready,
|
|
293
|
+
};
|
|
247
294
|
return {
|
|
248
|
-
async open(turn, playerIds) {
|
|
249
|
-
const created = await insertUnlessTaken(() => db.insert(turns).values(turn));
|
|
250
|
-
if (playerIds.length > 0) {
|
|
251
|
-
// Topped up rather than assumed: a re-run of the open step (a repaired seal) fills any row
|
|
252
|
-
// an interrupted one never wrote, and a player who already has a row keeps it untouched.
|
|
253
|
-
await db
|
|
254
|
-
.insert(turnOrders)
|
|
255
|
-
.values(playerIds.map((playerId) => ({ matchId: turn.matchId, turn: turn.number, playerId })))
|
|
256
|
-
.onConflictDoNothing();
|
|
257
|
-
}
|
|
258
|
-
return created;
|
|
259
|
-
},
|
|
260
|
-
async get(matchId, number) {
|
|
261
|
-
const rows = await db
|
|
262
|
-
.select()
|
|
263
|
-
.from(turns)
|
|
264
|
-
.where(and(eq(turns.matchId, matchId), eq(turns.number, number)));
|
|
265
|
-
return firstOrNull(rows.map(toTurn));
|
|
266
|
-
},
|
|
267
295
|
async submitOrders(matchId, number, playerId, submission) {
|
|
268
296
|
const turnIsOpen = exists(db
|
|
269
297
|
.select({ one: sql `1` })
|
|
@@ -283,6 +311,13 @@ function sqliteTurnRepository(db) {
|
|
|
283
311
|
.where(and(eq(turnOrders.matchId, matchId), eq(turnOrders.turn, number), eq(turnOrders.playerId, playerId)));
|
|
284
312
|
return firstOrNull(rows.map(toTurnOrders));
|
|
285
313
|
},
|
|
314
|
+
async getOrderSummary(matchId, number, playerId) {
|
|
315
|
+
const rows = await db
|
|
316
|
+
.select(orderSummaryColumns)
|
|
317
|
+
.from(turnOrders)
|
|
318
|
+
.where(and(eq(turnOrders.matchId, matchId), eq(turnOrders.turn, number), eq(turnOrders.playerId, playerId)));
|
|
319
|
+
return firstOrNull(rows);
|
|
320
|
+
},
|
|
286
321
|
async listOrders(matchId, number) {
|
|
287
322
|
const rows = await db
|
|
288
323
|
.select()
|
|
@@ -293,17 +328,36 @@ function sqliteTurnRepository(db) {
|
|
|
293
328
|
},
|
|
294
329
|
async listOrderSummaries(matchId, number) {
|
|
295
330
|
return db
|
|
296
|
-
.select(
|
|
297
|
-
matchId: turnOrders.matchId,
|
|
298
|
-
turn: turnOrders.turn,
|
|
299
|
-
playerId: turnOrders.playerId,
|
|
300
|
-
ordersHash: turnOrders.ordersHash,
|
|
301
|
-
ready: turnOrders.ready,
|
|
302
|
-
})
|
|
331
|
+
.select(orderSummaryColumns)
|
|
303
332
|
.from(turnOrders)
|
|
304
333
|
.where(and(eq(turnOrders.matchId, matchId), eq(turnOrders.turn, number)))
|
|
305
334
|
.orderBy(asc(turnOrders.playerId));
|
|
306
335
|
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function sqliteTurnRepository(db) {
|
|
339
|
+
const { matches, turns, turnOrders, turnReports } = schema;
|
|
340
|
+
return {
|
|
341
|
+
...sqliteTurnOrderMethods(db),
|
|
342
|
+
async open(turn, playerIds) {
|
|
343
|
+
const created = await insertUnlessTaken(() => db.insert(turns).values(turn));
|
|
344
|
+
if (playerIds.length > 0) {
|
|
345
|
+
// Topped up rather than assumed: a re-run of the open step (a repaired seal) fills any row
|
|
346
|
+
// an interrupted one never wrote, and a player who already has a row keeps it untouched.
|
|
347
|
+
await db
|
|
348
|
+
.insert(turnOrders)
|
|
349
|
+
.values(playerIds.map((playerId) => ({ matchId: turn.matchId, turn: turn.number, playerId })))
|
|
350
|
+
.onConflictDoNothing();
|
|
351
|
+
}
|
|
352
|
+
return created;
|
|
353
|
+
},
|
|
354
|
+
async get(matchId, number) {
|
|
355
|
+
const rows = await db
|
|
356
|
+
.select()
|
|
357
|
+
.from(turns)
|
|
358
|
+
.where(and(eq(turns.matchId, matchId), eq(turns.number, number)));
|
|
359
|
+
return firstOrNull(rows.map(toTurn));
|
|
360
|
+
},
|
|
307
361
|
async transition(matchId, number, from, patch) {
|
|
308
362
|
const rows = await db
|
|
309
363
|
.update(turns)
|
|
@@ -312,6 +366,25 @@ function sqliteTurnRepository(db) {
|
|
|
312
366
|
.returning({ number: turns.number });
|
|
313
367
|
return rows.length === 1;
|
|
314
368
|
},
|
|
369
|
+
async freezeSeal(matchId, number, frozen) {
|
|
370
|
+
const rows = await db
|
|
371
|
+
.update(turns)
|
|
372
|
+
.set(frozen)
|
|
373
|
+
.where(and(eq(turns.matchId, matchId), eq(turns.number, number), ne(turns.status, 'open'),
|
|
374
|
+
// The compare-and-swap is on what is being frozen, not on the status: the status is
|
|
375
|
+
// `sealed` for the whole window two callers can be computing a set in.
|
|
376
|
+
isNull(turns.orderSetHash)))
|
|
377
|
+
.returning({ number: turns.number });
|
|
378
|
+
return rows.length === 1;
|
|
379
|
+
},
|
|
380
|
+
async claimDesyncAnnouncement(matchId, number, at) {
|
|
381
|
+
const rows = await db
|
|
382
|
+
.update(turns)
|
|
383
|
+
.set({ desyncedAt: at })
|
|
384
|
+
.where(and(eq(turns.matchId, matchId), eq(turns.number, number), eq(turns.status, 'desynced'), isNull(turns.desyncedAt)))
|
|
385
|
+
.returning({ number: turns.number });
|
|
386
|
+
return rows.length === 1;
|
|
387
|
+
},
|
|
315
388
|
async rescheduleDeadline(matchId, number, deadlineAt) {
|
|
316
389
|
const rows = await db
|
|
317
390
|
.update(turns)
|
|
@@ -320,10 +393,25 @@ function sqliteTurnRepository(db) {
|
|
|
320
393
|
.returning({ number: turns.number });
|
|
321
394
|
return rows.length === 1;
|
|
322
395
|
},
|
|
396
|
+
/**
|
|
397
|
+
* An insert fed by a select over the turn row, so "the turn is still unsettled" is tested by
|
|
398
|
+
* the same statement that writes the report; the conflict clause makes it a replacement of the
|
|
399
|
+
* player's earlier one.
|
|
400
|
+
*/
|
|
323
401
|
async upsertReport(report) {
|
|
324
|
-
await db
|
|
402
|
+
const rows = await db
|
|
325
403
|
.insert(turnReports)
|
|
326
|
-
.
|
|
404
|
+
.select(db
|
|
405
|
+
.select({
|
|
406
|
+
matchId: sql `${report.matchId}`.as('match_id'),
|
|
407
|
+
turn: sql `${report.turn}`.as('turn'),
|
|
408
|
+
playerId: sql `${report.playerId}`.as('player_id'),
|
|
409
|
+
stateHash: sql `${report.stateHash}`.as('state_hash'),
|
|
410
|
+
finished: sql `${report.finished ? 1 : 0}`.as('finished'),
|
|
411
|
+
reportedAt: sql `${report.reportedAt.getTime()}`.as('reported_at'),
|
|
412
|
+
})
|
|
413
|
+
.from(turns)
|
|
414
|
+
.where(and(eq(turns.matchId, report.matchId), eq(turns.number, report.turn), inArray(turns.status, ['sealed', 'desynced']))))
|
|
327
415
|
.onConflictDoUpdate({
|
|
328
416
|
target: [turnReports.matchId, turnReports.turn, turnReports.playerId],
|
|
329
417
|
set: {
|
|
@@ -331,7 +419,9 @@ function sqliteTurnRepository(db) {
|
|
|
331
419
|
finished: report.finished,
|
|
332
420
|
reportedAt: report.reportedAt,
|
|
333
421
|
},
|
|
334
|
-
})
|
|
422
|
+
})
|
|
423
|
+
.returning({ playerId: turnReports.playerId });
|
|
424
|
+
return rows.length === 1;
|
|
335
425
|
},
|
|
336
426
|
async listReports(matchId, number) {
|
|
337
427
|
const rows = await db
|
|
@@ -353,7 +443,11 @@ function sqliteTurnRepository(db) {
|
|
|
353
443
|
return db
|
|
354
444
|
.select({ matchId: turns.matchId, number: turns.number })
|
|
355
445
|
.from(turns)
|
|
356
|
-
.
|
|
446
|
+
.innerJoin(matches, eq(matches.id, turns.matchId))
|
|
447
|
+
.where(and(eq(turns.status, 'open'), isNotNull(turns.deadlineAt), lte(turns.deadlineAt, now),
|
|
448
|
+
// Only a running match can seal. A desynced one keeps its expired deadline for the
|
|
449
|
+
// whole pause and would otherwise squat at the head of this page.
|
|
450
|
+
eq(matches.status, 'running')))
|
|
357
451
|
.orderBy(asc(turns.deadlineAt))
|
|
358
452
|
.limit(limit);
|
|
359
453
|
},
|
|
@@ -362,19 +456,31 @@ function sqliteTurnRepository(db) {
|
|
|
362
456
|
* `start` that died before opening turn 1) is as stalled as one whose seal stopped halfway,
|
|
363
457
|
* and an inner join would never see it. The repair opens the successor in both cases.
|
|
364
458
|
*/
|
|
365
|
-
async listStalledSeals(limit) {
|
|
366
|
-
return db
|
|
459
|
+
async listStalledSeals(limit, touchedSince) {
|
|
460
|
+
return (db
|
|
367
461
|
.select({ matchId: matches.id, number: matches.currentTurn })
|
|
368
462
|
.from(matches)
|
|
369
463
|
.leftJoin(turns, and(eq(turns.matchId, matches.id), eq(turns.number, matches.currentTurn)))
|
|
370
|
-
.where(and(inArray(matches.status, ['running', 'desynced']), or(isNull(turns.status), ne(turns.status, 'open'))))
|
|
371
|
-
|
|
372
|
-
.
|
|
464
|
+
.where(and(inArray(matches.status, ['running', 'desynced']), or(isNull(turns.status), ne(turns.status, 'open')), ...(touchedSince ? [gte(matches.updatedAt, touchedSince)] : [])))
|
|
465
|
+
// Ordered by the recency the window is taken on, so a match that keeps throwing cannot pin
|
|
466
|
+
// the head of the page and hide every other stall behind it.
|
|
467
|
+
.orderBy(desc(matches.updatedAt), asc(matches.id))
|
|
468
|
+
.limit(limit));
|
|
373
469
|
},
|
|
374
470
|
};
|
|
375
471
|
}
|
|
376
472
|
function sqliteSnapshotRepository(db) {
|
|
377
473
|
const { snapshots } = schema;
|
|
474
|
+
const snapshotSummaryColumns = {
|
|
475
|
+
matchId: snapshots.matchId,
|
|
476
|
+
turn: snapshots.turn,
|
|
477
|
+
formatVersion: snapshots.formatVersion,
|
|
478
|
+
protocolVersion: snapshots.protocolVersion,
|
|
479
|
+
sessionVersion: snapshots.sessionVersion,
|
|
480
|
+
stateHash: snapshots.stateHash,
|
|
481
|
+
uploadedByPlayerId: snapshots.uploadedByPlayerId,
|
|
482
|
+
uploadedAt: snapshots.uploadedAt,
|
|
483
|
+
};
|
|
378
484
|
return {
|
|
379
485
|
async put(snapshot) {
|
|
380
486
|
await db
|
|
@@ -384,6 +490,10 @@ function sqliteSnapshotRepository(db) {
|
|
|
384
490
|
target: [snapshots.matchId, snapshots.turn],
|
|
385
491
|
set: {
|
|
386
492
|
formatVersion: snapshot.formatVersion,
|
|
493
|
+
// Re-uploading a turn replaces the bytes, so the versions they were written under have
|
|
494
|
+
// to move with them; leaving them behind described the new body with the old client's.
|
|
495
|
+
protocolVersion: snapshot.protocolVersion,
|
|
496
|
+
sessionVersion: snapshot.sessionVersion,
|
|
387
497
|
stateHash: snapshot.stateHash,
|
|
388
498
|
uploadedByPlayerId: snapshot.uploadedByPlayerId,
|
|
389
499
|
uploadedAt: snapshot.uploadedAt,
|
|
@@ -398,6 +508,13 @@ function sqliteSnapshotRepository(db) {
|
|
|
398
508
|
.where(and(eq(snapshots.matchId, matchId), eq(snapshots.turn, turn)));
|
|
399
509
|
return firstOrNull(rows.map(toSnapshot));
|
|
400
510
|
},
|
|
511
|
+
async getSummary(matchId, turn) {
|
|
512
|
+
const rows = await db
|
|
513
|
+
.select(snapshotSummaryColumns)
|
|
514
|
+
.from(snapshots)
|
|
515
|
+
.where(and(eq(snapshots.matchId, matchId), eq(snapshots.turn, turn)));
|
|
516
|
+
return firstOrNull(rows.map(toSnapshotSummary));
|
|
517
|
+
},
|
|
401
518
|
async getLatest(matchId) {
|
|
402
519
|
const rows = await db
|
|
403
520
|
.select()
|
|
@@ -409,16 +526,7 @@ function sqliteSnapshotRepository(db) {
|
|
|
409
526
|
},
|
|
410
527
|
async getLatestSummary(matchId) {
|
|
411
528
|
const rows = await db
|
|
412
|
-
.select(
|
|
413
|
-
matchId: snapshots.matchId,
|
|
414
|
-
turn: snapshots.turn,
|
|
415
|
-
formatVersion: snapshots.formatVersion,
|
|
416
|
-
protocolVersion: snapshots.protocolVersion,
|
|
417
|
-
sessionVersion: snapshots.sessionVersion,
|
|
418
|
-
stateHash: snapshots.stateHash,
|
|
419
|
-
uploadedByPlayerId: snapshots.uploadedByPlayerId,
|
|
420
|
-
uploadedAt: snapshots.uploadedAt,
|
|
421
|
-
})
|
|
529
|
+
.select(snapshotSummaryColumns)
|
|
422
530
|
.from(snapshots)
|
|
423
531
|
.where(eq(snapshots.matchId, matchId))
|
|
424
532
|
.orderBy(desc(snapshots.turn))
|
|
@@ -444,135 +552,4 @@ function sqliteSnapshotRepository(db) {
|
|
|
444
552
|
},
|
|
445
553
|
};
|
|
446
554
|
}
|
|
447
|
-
function sqliteTakeoverRepository(db) {
|
|
448
|
-
const { takeoverPrompts, takeoverVotes } = schema;
|
|
449
|
-
return {
|
|
450
|
-
async openPrompt(matchId, playerId, turn, openedAt) {
|
|
451
|
-
const rows = await db
|
|
452
|
-
.insert(takeoverPrompts)
|
|
453
|
-
.values({ matchId, playerId, turn, openedAt })
|
|
454
|
-
.onConflictDoNothing()
|
|
455
|
-
.returning({ playerId: takeoverPrompts.playerId });
|
|
456
|
-
return rows.length === 1;
|
|
457
|
-
},
|
|
458
|
-
async closePrompt(matchId, playerId) {
|
|
459
|
-
// Votes first: a death between the two leaves an open prompt with no votes, which is the
|
|
460
|
-
// safe state, rather than votes that a later prompt for the same seat would inherit.
|
|
461
|
-
await db
|
|
462
|
-
.delete(takeoverVotes)
|
|
463
|
-
.where(and(eq(takeoverVotes.matchId, matchId), eq(takeoverVotes.targetPlayerId, playerId)));
|
|
464
|
-
await db
|
|
465
|
-
.delete(takeoverPrompts)
|
|
466
|
-
.where(and(eq(takeoverPrompts.matchId, matchId), eq(takeoverPrompts.playerId, playerId)));
|
|
467
|
-
},
|
|
468
|
-
async hasOpenPrompts(matchId) {
|
|
469
|
-
const rows = await db
|
|
470
|
-
.select({ playerId: takeoverPrompts.playerId })
|
|
471
|
-
.from(takeoverPrompts)
|
|
472
|
-
.where(eq(takeoverPrompts.matchId, matchId))
|
|
473
|
-
.limit(1);
|
|
474
|
-
return rows.length > 0;
|
|
475
|
-
},
|
|
476
|
-
async listOpenPrompts(matchId) {
|
|
477
|
-
const rows = await db
|
|
478
|
-
.select({ playerId: takeoverPrompts.playerId })
|
|
479
|
-
.from(takeoverPrompts)
|
|
480
|
-
.where(eq(takeoverPrompts.matchId, matchId))
|
|
481
|
-
.orderBy(asc(takeoverPrompts.playerId));
|
|
482
|
-
return rows.map((row) => row.playerId);
|
|
483
|
-
},
|
|
484
|
-
/**
|
|
485
|
-
* An insert fed by a select over the prompt row, so "the prompt is open" is tested by the same
|
|
486
|
-
* statement that writes the vote; the conflict clause makes it a replacement of the voter's
|
|
487
|
-
* earlier choice.
|
|
488
|
-
*/
|
|
489
|
-
async castVote(matchId, targetPlayerId, voterPlayerId, decision, castAt) {
|
|
490
|
-
const rows = await db
|
|
491
|
-
.insert(takeoverVotes)
|
|
492
|
-
.select(db
|
|
493
|
-
.select({
|
|
494
|
-
matchId: sql `${matchId}`.as('match_id'),
|
|
495
|
-
targetPlayerId: sql `${targetPlayerId}`.as('target_player_id'),
|
|
496
|
-
voterPlayerId: sql `${voterPlayerId}`.as('voter_player_id'),
|
|
497
|
-
decision: sql `${decision}`.as('decision'),
|
|
498
|
-
castAt: sql `${castAt.getTime()}`.as('cast_at'),
|
|
499
|
-
})
|
|
500
|
-
.from(takeoverPrompts)
|
|
501
|
-
.where(and(eq(takeoverPrompts.matchId, matchId), eq(takeoverPrompts.playerId, targetPlayerId))))
|
|
502
|
-
.onConflictDoUpdate({
|
|
503
|
-
target: [
|
|
504
|
-
takeoverVotes.matchId,
|
|
505
|
-
takeoverVotes.targetPlayerId,
|
|
506
|
-
takeoverVotes.voterPlayerId,
|
|
507
|
-
],
|
|
508
|
-
set: { decision, castAt },
|
|
509
|
-
})
|
|
510
|
-
.returning({ voterPlayerId: takeoverVotes.voterPlayerId });
|
|
511
|
-
return rows.length === 1;
|
|
512
|
-
},
|
|
513
|
-
async listVotes(matchId, targetPlayerId) {
|
|
514
|
-
const rows = await db
|
|
515
|
-
.select()
|
|
516
|
-
.from(takeoverVotes)
|
|
517
|
-
.where(and(eq(takeoverVotes.matchId, matchId), eq(takeoverVotes.targetPlayerId, targetPlayerId)))
|
|
518
|
-
.orderBy(asc(takeoverVotes.voterPlayerId));
|
|
519
|
-
return rows.map(toTakeoverVote);
|
|
520
|
-
},
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
function sqliteEventRepository(db) {
|
|
524
|
-
const { matchEvents } = schema;
|
|
525
|
-
return {
|
|
526
|
-
/**
|
|
527
|
-
* The sequence number comes from the log itself inside the insert, so the allocation cannot be
|
|
528
|
-
* separated from the write: a committed `seq` therefore implies every lower one is committed,
|
|
529
|
-
* which is the invariant a stream cursor relies on. Concurrent appends collide on the primary
|
|
530
|
-
* key and the loser simply re-reads the maximum.
|
|
531
|
-
*/
|
|
532
|
-
async append(event) {
|
|
533
|
-
const nextSeq = sql `(select coalesce(max(${matchEvents.seq}), 0) + 1 from ${matchEvents} where ${eq(matchEvents.matchId, event.matchId)})`;
|
|
534
|
-
for (let attempt = 1; attempt <= APPEND_ATTEMPTS; attempt += 1) {
|
|
535
|
-
try {
|
|
536
|
-
const rows = await db
|
|
537
|
-
.insert(matchEvents)
|
|
538
|
-
.values({
|
|
539
|
-
matchId: event.matchId,
|
|
540
|
-
seq: nextSeq,
|
|
541
|
-
type: event.type,
|
|
542
|
-
payload: event.payload,
|
|
543
|
-
createdAt: new Date(event.createdAt),
|
|
544
|
-
})
|
|
545
|
-
.returning({ seq: matchEvents.seq });
|
|
546
|
-
const row = rows[0];
|
|
547
|
-
if (!row)
|
|
548
|
-
throw new Error(`append to ${event.matchId} reported no row`);
|
|
549
|
-
return { ...event, seq: row.seq };
|
|
550
|
-
}
|
|
551
|
-
catch (error) {
|
|
552
|
-
if (!isUniqueViolation(error) || attempt === APPEND_ATTEMPTS)
|
|
553
|
-
throw error;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
throw new Error(`could not append an event for ${event.matchId}`);
|
|
557
|
-
},
|
|
558
|
-
async listAfter(matchId, afterSeq, limit) {
|
|
559
|
-
const rows = await db
|
|
560
|
-
.select()
|
|
561
|
-
.from(matchEvents)
|
|
562
|
-
.where(and(eq(matchEvents.matchId, matchId), sql `${matchEvents.seq} > ${afterSeq}`))
|
|
563
|
-
.orderBy(asc(matchEvents.seq))
|
|
564
|
-
.limit(limit);
|
|
565
|
-
return rows.map(toEvent);
|
|
566
|
-
},
|
|
567
|
-
async lastSeq(matchId) {
|
|
568
|
-
const rows = await db
|
|
569
|
-
.select({ seq: matchEvents.seq })
|
|
570
|
-
.from(matchEvents)
|
|
571
|
-
.where(eq(matchEvents.matchId, matchId))
|
|
572
|
-
.orderBy(desc(matchEvents.seq))
|
|
573
|
-
.limit(1);
|
|
574
|
-
return rows[0]?.seq ?? 0;
|
|
575
|
-
},
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
555
|
//# sourceMappingURL=repositories.js.map
|