@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.
Files changed (59) hide show
  1. package/dist/node/index.d.ts +11 -3
  2. package/dist/node/index.d.ts.map +1 -1
  3. package/dist/node/index.js +26 -7
  4. package/dist/node/index.js.map +1 -1
  5. package/dist/postgres/database.d.ts +10 -0
  6. package/dist/postgres/database.d.ts.map +1 -0
  7. package/dist/postgres/database.js +2 -0
  8. package/dist/postgres/database.js.map +1 -0
  9. package/dist/postgres/events.d.ts +9 -0
  10. package/dist/postgres/events.d.ts.map +1 -0
  11. package/dist/postgres/events.js +56 -0
  12. package/dist/postgres/events.js.map +1 -0
  13. package/dist/postgres/repositories.d.ts +2 -3
  14. package/dist/postgres/repositories.d.ts.map +1 -1
  15. package/dist/postgres/repositories.js +188 -237
  16. package/dist/postgres/repositories.js.map +1 -1
  17. package/dist/postgres/schema.d.ts +26 -9
  18. package/dist/postgres/schema.d.ts.map +1 -1
  19. package/dist/postgres/schema.js +6 -0
  20. package/dist/postgres/schema.js.map +1 -1
  21. package/dist/postgres/takeovers.d.ts +12 -0
  22. package/dist/postgres/takeovers.d.ts.map +1 -0
  23. package/dist/postgres/takeovers.js +88 -0
  24. package/dist/postgres/takeovers.js.map +1 -0
  25. package/dist/shared/constraints.d.ts +18 -0
  26. package/dist/shared/constraints.d.ts.map +1 -1
  27. package/dist/shared/constraints.js +36 -0
  28. package/dist/shared/constraints.js.map +1 -1
  29. package/dist/shared/mappers.d.ts +28 -5
  30. package/dist/shared/mappers.d.ts.map +1 -1
  31. package/dist/shared/mappers.js +31 -0
  32. package/dist/shared/mappers.js.map +1 -1
  33. package/dist/sqlite/database.d.ts +12 -0
  34. package/dist/sqlite/database.d.ts.map +1 -0
  35. package/dist/sqlite/database.js +2 -0
  36. package/dist/sqlite/database.js.map +1 -0
  37. package/dist/sqlite/events.d.ts +9 -0
  38. package/dist/sqlite/events.d.ts.map +1 -0
  39. package/dist/sqlite/events.js +56 -0
  40. package/dist/sqlite/events.js.map +1 -0
  41. package/dist/sqlite/repositories.d.ts +2 -8
  42. package/dist/sqlite/repositories.d.ts.map +1 -1
  43. package/dist/sqlite/repositories.js +214 -237
  44. package/dist/sqlite/repositories.js.map +1 -1
  45. package/dist/sqlite/schema.d.ts +26 -9
  46. package/dist/sqlite/schema.d.ts.map +1 -1
  47. package/dist/sqlite/schema.js +6 -0
  48. package/dist/sqlite/schema.js.map +1 -1
  49. package/dist/sqlite/takeovers.d.ts +12 -0
  50. package/dist/sqlite/takeovers.d.ts.map +1 -0
  51. package/dist/sqlite/takeovers.js +88 -0
  52. package/dist/sqlite/takeovers.js.map +1 -0
  53. package/migrations/postgres/0005_old_husk.sql +6 -0
  54. package/migrations/postgres/meta/0005_snapshot.json +854 -0
  55. package/migrations/postgres/meta/_journal.json +8 -1
  56. package/migrations/sqlite/0005_lumpy_stingray.sql +6 -0
  57. package/migrations/sqlite/meta/0005_snapshot.json +844 -0
  58. package/migrations/sqlite/meta/_journal.json +8 -1
  59. 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 { APPEND_ATTEMPTS, insertUnlessTaken, isUniqueViolation } from '../shared/constraints';
3
- import { firstOrNull, toEvent, toMatch, toMatchInsert, toPlayer, toSnapshot, toSnapshotSummary, toTakeoverVote, toTurn, toTurnOrders, toTurnReport, } from '../shared/mappers';
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
- playerCount: matches.seatCount,
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(({ passwordHash, createdAt, ...rest }) => ({
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
- // Children cascade from the match row, so one delete takes the whole match with it.
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), notExists(db
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
- .delete(matches)
128
- .where(inArray(matches.id, collectable))
129
- .returning({ id: matches.id });
130
- return rows.length;
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.delete(players).where(eq(players.id, playerId));
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
- function sqliteTurnRepository(db) {
246
- const { matches, turns, turnOrders, turnReports } = schema;
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
- .values(report)
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
- .where(and(eq(turns.status, 'open'), isNotNull(turns.deadlineAt), lte(turns.deadlineAt, now)))
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
- .orderBy(asc(matches.id))
372
- .limit(limit);
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