@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 { postgresEventRepository } from './events';
4
5
  import * as schema from './schema';
6
+ import { postgresTakeoverRepository } from './takeovers';
5
7
  export function createPostgresStorage(db) {
6
8
  return {
7
9
  matches: postgresMatchRepository(db),
@@ -13,7 +15,15 @@ export function createPostgresStorage(db) {
13
15
  };
14
16
  }
15
17
  function postgresMatchRepository(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,33 @@ function postgresMatchRepository(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
+ /** See the SQLite twin: the counts and the snapshot test ride in the listing statement. */
27
38
  async listPublicLobbies(limit) {
39
+ const humanSeats = sql `(select count(*) from ${players} where ${players.matchId} = ${matches.id} and ${players.status} in ('active', 'takeoverPending'))`;
28
40
  const rows = await db
29
41
  .select({
30
42
  id: matches.id,
31
43
  joinCode: matches.joinCode,
32
44
  name: matches.name,
33
45
  hostDisplayName: players.displayName,
34
- playerCount: matches.seatCount,
46
+ seatCount: matches.seatCount,
47
+ humanCount: humanSeats,
35
48
  maxPlayers: matches.maxPlayers,
36
49
  passwordHash: matches.passwordHash,
37
50
  status: matches.status,
38
51
  settings: matches.settings,
39
52
  createdAt: matches.createdAt,
53
+ hasSnapshot: exists(db
54
+ .select({ one: sql `1` })
55
+ .from(snapshots)
56
+ .where(eq(snapshots.matchId, matches.id))),
40
57
  })
41
58
  .from(matches)
42
59
  .innerJoin(players, eq(players.id, matches.hostPlayerId))
43
- .where(and(inArray(matches.status, ['lobby', 'running']), eq(matches.visibility, 'public')))
60
+ .where(and(inArray(matches.status, ['lobby', 'running']), eq(matches.visibility, 'public'), or(ne(matches.status, 'running'), sql `${humanSeats} > 0`)))
44
61
  .orderBy(desc(matches.createdAt), asc(matches.id))
45
62
  .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
- }));
63
+ return rows.map(toPublicLobbyRow);
55
64
  },
56
65
  async claimSeat(matchId) {
57
66
  const rows = await db
@@ -106,33 +115,34 @@ function postgresMatchRepository(db) {
106
115
  .where(and(inArray(matches.status, [...statuses]), lt(matches.updatedAt, before)))
107
116
  .limit(limit)
108
117
  .for('update', { skipLocked: true });
109
- // Children cascade from the match row, so one delete takes the whole match with it.
110
- const rows = await db
111
- .delete(matches)
112
- .where(inArray(matches.id, collectable))
113
- .returning({ id: matches.id });
114
- return rows.length;
118
+ return deleteCollectable(collectable);
115
119
  },
116
120
  /**
117
121
  * The same delete, aimed at a live match nobody is in any more. The roster test is a
118
122
  * `not exists` over the players rather than a count: one active row is enough to spare the
119
123
  * match, and asking whether any exists stops at the first.
120
124
  */
121
- async deleteAbandonedLive(before, limit) {
125
+ async deleteAbandonedLive(before, limit, requireEmptyRoster) {
126
+ const emptyRoster = notExists(db
127
+ .select({ one: sql `1` })
128
+ .from(players)
129
+ .where(and(eq(players.matchId, matches.id), eq(players.status, 'active'))));
122
130
  const collectable = db
123
131
  .select({ id: matches.id })
124
132
  .from(matches)
125
- .where(and(inArray(matches.status, ['running', 'desynced']), lt(matches.updatedAt, before), notExists(db
126
- .select({ one: sql `1` })
127
- .from(players)
128
- .where(and(eq(players.matchId, matches.id), eq(players.status, 'active'))))))
133
+ .where(and(inArray(matches.status, ['running', 'desynced']), lt(matches.updatedAt, before), ...(requireEmptyRoster ? [emptyRoster] : [])))
129
134
  .limit(limit)
130
135
  .for('update', { skipLocked: true });
136
+ return deleteCollectable(collectable);
137
+ },
138
+ async listDesynced(limit, touchedSince) {
131
139
  const rows = await db
132
- .delete(matches)
133
- .where(inArray(matches.id, collectable))
134
- .returning({ id: matches.id });
135
- return rows.length;
140
+ .select({ id: matches.id })
141
+ .from(matches)
142
+ .where(and(eq(matches.status, 'desynced'), ...(touchedSince ? [gte(matches.updatedAt, touchedSince)] : [])))
143
+ .orderBy(asc(matches.updatedAt))
144
+ .limit(limit);
145
+ return rows.map((row) => row.id);
136
146
  },
137
147
  async transition(matchId, from, patch) {
138
148
  const rows = await db
@@ -144,6 +154,28 @@ function postgresMatchRepository(db) {
144
154
  },
145
155
  };
146
156
  }
157
+ /**
158
+ * The select list `create` and `createLate` feed their insert from: the player's own values, each
159
+ * under the column name it is inserted as.
160
+ *
161
+ * The list is written out, which means it does NOT get Drizzle's column mapping: a column added to
162
+ * `players` later has to be added here too, in the storage form the column expects. The
163
+ * conformance suite compares a created player against its fixture field by field, so a dropped
164
+ * column fails there rather than going unnoticed.
165
+ */
166
+ function playerValues(player) {
167
+ return {
168
+ id: sql `${player.id}`.as('id'),
169
+ matchId: sql `${player.matchId}`.as('match_id'),
170
+ slot: sql `${player.slot}`.as('slot'),
171
+ joinOrder: sql `${player.joinOrder}`.as('join_order'),
172
+ displayName: sql `${player.displayName}`.as('display_name'),
173
+ portraitId: sql `${player.portraitId}`.as('portrait_id'),
174
+ tokenHash: sql `${player.tokenHash}`.as('token_hash'),
175
+ status: sql `${player.status}`.as('status'),
176
+ joinedAt: sql `${player.joinedAt}`.as('joined_at'),
177
+ };
178
+ }
147
179
  function postgresPlayerRepository(db) {
148
180
  const { matches, players } = schema;
149
181
  return {
@@ -152,27 +184,12 @@ function postgresPlayerRepository(db) {
152
184
  * by the same statement that writes the player. The seat counter was claimed a moment earlier
153
185
  * and the match may have started since; without this the player would land in a running match
154
186
  * that had already seated its roster, holding a seat nobody can play.
155
- *
156
- * The select list is written out, which means it does NOT get Drizzle's column mapping: a
157
- * column added to `players` later has to be added here too, in the storage form the column
158
- * expects. The conformance suite compares a created player against its fixture field by field,
159
- * so a dropped column fails there rather than going unnoticed.
160
187
  */
161
188
  async create(player) {
162
189
  const rows = await db
163
190
  .insert(players)
164
191
  .select(db
165
- .select({
166
- id: sql `${player.id}`.as('id'),
167
- matchId: sql `${player.matchId}`.as('match_id'),
168
- slot: sql `${player.slot}`.as('slot'),
169
- joinOrder: sql `${player.joinOrder}`.as('join_order'),
170
- displayName: sql `${player.displayName}`.as('display_name'),
171
- portraitId: sql `${player.portraitId}`.as('portrait_id'),
172
- tokenHash: sql `${player.tokenHash}`.as('token_hash'),
173
- status: sql `${player.status}`.as('status'),
174
- joinedAt: sql `${player.joinedAt}`.as('joined_at'),
175
- })
192
+ .select(playerValues(player))
176
193
  .from(matches)
177
194
  .where(and(eq(matches.id, player.matchId), eq(matches.status, 'lobby'))))
178
195
  .returning({ id: players.id });
@@ -186,19 +203,11 @@ function postgresPlayerRepository(db) {
186
203
  const rows = await db
187
204
  .insert(players)
188
205
  .select(db
189
- .select({
190
- id: sql `${player.id}`.as('id'),
191
- matchId: sql `${player.matchId}`.as('match_id'),
192
- slot: sql `${player.slot}`.as('slot'),
193
- joinOrder: sql `${player.joinOrder}`.as('join_order'),
194
- displayName: sql `${player.displayName}`.as('display_name'),
195
- portraitId: sql `${player.portraitId}`.as('portrait_id'),
196
- tokenHash: sql `${player.tokenHash}`.as('token_hash'),
197
- status: sql `${player.status}`.as('status'),
198
- joinedAt: sql `${player.joinedAt}`.as('joined_at'),
199
- })
206
+ .select(playerValues(player))
200
207
  .from(matches)
201
- .where(and(eq(matches.id, player.matchId), eq(matches.status, 'running'), notExists(occupied))))
208
+ .where(and(eq(matches.id, player.matchId), eq(matches.status, 'running'), notExists(occupied),
209
+ // Capacity in the same statement as the insert; see the SQLite twin.
210
+ sql `(select count(*) from ${players} where ${players.matchId} = ${player.matchId}) < ${matches.maxPlayers}`)))
202
211
  .onConflictDoNothing()
203
212
  .returning({ id: players.id });
204
213
  return rows.length === 1;
@@ -217,6 +226,14 @@ function postgresPlayerRepository(db) {
217
226
  .orderBy(asc(players.slot), asc(players.joinOrder), asc(players.id));
218
227
  return rows.map(toPlayer);
219
228
  },
229
+ async listSeats(matchIds) {
230
+ if (matchIds.length === 0)
231
+ return [];
232
+ return db
233
+ .select({ matchId: players.matchId, slot: players.slot })
234
+ .from(players)
235
+ .where(inArray(players.matchId, [...matchIds]));
236
+ },
220
237
  async setStatus(playerId, status) {
221
238
  await db.update(players).set({ status }).where(eq(players.id, playerId));
222
239
  },
@@ -243,32 +260,25 @@ function postgresPlayerRepository(db) {
243
260
  .where(inArray(players.id, assignments.map(({ playerId }) => playerId)));
244
261
  },
245
262
  async delete(playerId) {
246
- await db.delete(players).where(eq(players.id, playerId));
263
+ const rows = await db
264
+ .delete(players)
265
+ .where(eq(players.id, playerId))
266
+ .returning({ id: players.id });
267
+ return rows.length === 1;
247
268
  },
248
269
  };
249
270
  }
250
- function postgresTurnRepository(db) {
251
- const { matches, turns, turnOrders, turnReports } = schema;
271
+ /** The orders half of the turn repository; see the SQLite twin. */
272
+ function postgresTurnOrderMethods(db) {
273
+ const { turns, turnOrders } = schema;
274
+ const orderSummaryColumns = {
275
+ matchId: turnOrders.matchId,
276
+ turn: turnOrders.turn,
277
+ playerId: turnOrders.playerId,
278
+ ordersHash: turnOrders.ordersHash,
279
+ ready: turnOrders.ready,
280
+ };
252
281
  return {
253
- async open(turn, playerIds) {
254
- const created = await insertUnlessTaken(() => db.insert(turns).values(turn));
255
- if (playerIds.length > 0) {
256
- // Topped up rather than assumed: a re-run of the open step (a repaired seal) fills any row
257
- // an interrupted one never wrote, and a player who already has a row keeps it untouched.
258
- await db
259
- .insert(turnOrders)
260
- .values(playerIds.map((playerId) => ({ matchId: turn.matchId, turn: turn.number, playerId })))
261
- .onConflictDoNothing();
262
- }
263
- return created;
264
- },
265
- async get(matchId, number) {
266
- const rows = await db
267
- .select()
268
- .from(turns)
269
- .where(and(eq(turns.matchId, matchId), eq(turns.number, number)));
270
- return firstOrNull(rows.map(toTurn));
271
- },
272
282
  async submitOrders(matchId, number, playerId, submission) {
273
283
  const turnIsOpen = exists(db
274
284
  .select({ one: sql `1` })
@@ -288,6 +298,13 @@ function postgresTurnRepository(db) {
288
298
  .where(and(eq(turnOrders.matchId, matchId), eq(turnOrders.turn, number), eq(turnOrders.playerId, playerId)));
289
299
  return firstOrNull(rows.map(toTurnOrders));
290
300
  },
301
+ async getOrderSummary(matchId, number, playerId) {
302
+ const rows = await db
303
+ .select(orderSummaryColumns)
304
+ .from(turnOrders)
305
+ .where(and(eq(turnOrders.matchId, matchId), eq(turnOrders.turn, number), eq(turnOrders.playerId, playerId)));
306
+ return firstOrNull(rows);
307
+ },
291
308
  async listOrders(matchId, number) {
292
309
  const rows = await db
293
310
  .select()
@@ -298,17 +315,36 @@ function postgresTurnRepository(db) {
298
315
  },
299
316
  async listOrderSummaries(matchId, number) {
300
317
  return db
301
- .select({
302
- matchId: turnOrders.matchId,
303
- turn: turnOrders.turn,
304
- playerId: turnOrders.playerId,
305
- ordersHash: turnOrders.ordersHash,
306
- ready: turnOrders.ready,
307
- })
318
+ .select(orderSummaryColumns)
308
319
  .from(turnOrders)
309
320
  .where(and(eq(turnOrders.matchId, matchId), eq(turnOrders.turn, number)))
310
321
  .orderBy(asc(turnOrders.playerId));
311
322
  },
323
+ };
324
+ }
325
+ function postgresTurnRepository(db) {
326
+ const { matches, turns, turnOrders, turnReports } = schema;
327
+ return {
328
+ ...postgresTurnOrderMethods(db),
329
+ async open(turn, playerIds) {
330
+ const created = await insertUnlessTaken(() => db.insert(turns).values(turn));
331
+ if (playerIds.length > 0) {
332
+ // Topped up rather than assumed: a re-run of the open step (a repaired seal) fills any row
333
+ // an interrupted one never wrote, and a player who already has a row keeps it untouched.
334
+ await db
335
+ .insert(turnOrders)
336
+ .values(playerIds.map((playerId) => ({ matchId: turn.matchId, turn: turn.number, playerId })))
337
+ .onConflictDoNothing();
338
+ }
339
+ return created;
340
+ },
341
+ async get(matchId, number) {
342
+ const rows = await db
343
+ .select()
344
+ .from(turns)
345
+ .where(and(eq(turns.matchId, matchId), eq(turns.number, number)));
346
+ return firstOrNull(rows.map(toTurn));
347
+ },
312
348
  async transition(matchId, number, from, patch) {
313
349
  const rows = await db
314
350
  .update(turns)
@@ -317,6 +353,24 @@ function postgresTurnRepository(db) {
317
353
  .returning({ number: turns.number });
318
354
  return rows.length === 1;
319
355
  },
356
+ async freezeSeal(matchId, number, frozen) {
357
+ const rows = await db
358
+ .update(turns)
359
+ .set(frozen)
360
+ .where(and(eq(turns.matchId, matchId), eq(turns.number, number), ne(turns.status, 'open'),
361
+ // The compare-and-swap is on what is being frozen, not on the status; see the twin.
362
+ isNull(turns.orderSetHash)))
363
+ .returning({ number: turns.number });
364
+ return rows.length === 1;
365
+ },
366
+ async claimDesyncAnnouncement(matchId, number, at) {
367
+ const rows = await db
368
+ .update(turns)
369
+ .set({ desyncedAt: at })
370
+ .where(and(eq(turns.matchId, matchId), eq(turns.number, number), eq(turns.status, 'desynced'), isNull(turns.desyncedAt)))
371
+ .returning({ number: turns.number });
372
+ return rows.length === 1;
373
+ },
320
374
  async rescheduleDeadline(matchId, number, deadlineAt) {
321
375
  const rows = await db
322
376
  .update(turns)
@@ -325,10 +379,21 @@ function postgresTurnRepository(db) {
325
379
  .returning({ number: turns.number });
326
380
  return rows.length === 1;
327
381
  },
382
+ /** Conditional on the turn still awaiting a verdict; see the SQLite twin. */
328
383
  async upsertReport(report) {
329
- await db
384
+ const rows = await db
330
385
  .insert(turnReports)
331
- .values(report)
386
+ .select(db
387
+ .select({
388
+ matchId: sql `${report.matchId}`.as('match_id'),
389
+ turn: sql `${report.turn}`.as('turn'),
390
+ playerId: sql `${report.playerId}`.as('player_id'),
391
+ stateHash: sql `${report.stateHash}`.as('state_hash'),
392
+ finished: sql `${report.finished}`.as('finished'),
393
+ reportedAt: sql `${report.reportedAt}`.as('reported_at'),
394
+ })
395
+ .from(turns)
396
+ .where(and(eq(turns.matchId, report.matchId), eq(turns.number, report.turn), inArray(turns.status, ['sealed', 'desynced']))))
332
397
  .onConflictDoUpdate({
333
398
  target: [turnReports.matchId, turnReports.turn, turnReports.playerId],
334
399
  set: {
@@ -336,7 +401,9 @@ function postgresTurnRepository(db) {
336
401
  finished: report.finished,
337
402
  reportedAt: report.reportedAt,
338
403
  },
339
- });
404
+ })
405
+ .returning({ playerId: turnReports.playerId });
406
+ return rows.length === 1;
340
407
  },
341
408
  async listReports(matchId, number) {
342
409
  const rows = await db
@@ -358,7 +425,10 @@ function postgresTurnRepository(db) {
358
425
  return db
359
426
  .select({ matchId: turns.matchId, number: turns.number })
360
427
  .from(turns)
361
- .where(and(eq(turns.status, 'open'), isNotNull(turns.deadlineAt), lte(turns.deadlineAt, now)))
428
+ .innerJoin(matches, eq(matches.id, turns.matchId))
429
+ .where(and(eq(turns.status, 'open'), isNotNull(turns.deadlineAt), lte(turns.deadlineAt, now),
430
+ // Only a running match can seal; a desynced one would squat at the head of this page.
431
+ eq(matches.status, 'running')))
362
432
  .orderBy(asc(turns.deadlineAt))
363
433
  .limit(limit);
364
434
  },
@@ -367,19 +437,30 @@ function postgresTurnRepository(db) {
367
437
  * `start` that died before opening turn 1) is as stalled as one whose seal stopped halfway,
368
438
  * and an inner join would never see it. The repair opens the successor in both cases.
369
439
  */
370
- async listStalledSeals(limit) {
371
- return db
440
+ async listStalledSeals(limit, touchedSince) {
441
+ return (db
372
442
  .select({ matchId: matches.id, number: matches.currentTurn })
373
443
  .from(matches)
374
444
  .leftJoin(turns, and(eq(turns.matchId, matches.id), eq(turns.number, matches.currentTurn)))
375
- .where(and(inArray(matches.status, ['running', 'desynced']), or(isNull(turns.status), ne(turns.status, 'open'))))
376
- .orderBy(asc(matches.id))
377
- .limit(limit);
445
+ .where(and(inArray(matches.status, ['running', 'desynced']), or(isNull(turns.status), ne(turns.status, 'open')), ...(touchedSince ? [gte(matches.updatedAt, touchedSince)] : [])))
446
+ // Ordered by the recency the window is taken on; see the SQLite twin.
447
+ .orderBy(desc(matches.updatedAt), asc(matches.id))
448
+ .limit(limit));
378
449
  },
379
450
  };
380
451
  }
381
452
  function postgresSnapshotRepository(db) {
382
453
  const { snapshots } = schema;
454
+ const snapshotSummaryColumns = {
455
+ matchId: snapshots.matchId,
456
+ turn: snapshots.turn,
457
+ formatVersion: snapshots.formatVersion,
458
+ protocolVersion: snapshots.protocolVersion,
459
+ sessionVersion: snapshots.sessionVersion,
460
+ stateHash: snapshots.stateHash,
461
+ uploadedByPlayerId: snapshots.uploadedByPlayerId,
462
+ uploadedAt: snapshots.uploadedAt,
463
+ };
383
464
  return {
384
465
  async put(snapshot) {
385
466
  await db
@@ -389,6 +470,9 @@ function postgresSnapshotRepository(db) {
389
470
  target: [snapshots.matchId, snapshots.turn],
390
471
  set: {
391
472
  formatVersion: snapshot.formatVersion,
473
+ // The versions move with the bytes they describe; see the SQLite twin.
474
+ protocolVersion: snapshot.protocolVersion,
475
+ sessionVersion: snapshot.sessionVersion,
392
476
  stateHash: snapshot.stateHash,
393
477
  uploadedByPlayerId: snapshot.uploadedByPlayerId,
394
478
  uploadedAt: snapshot.uploadedAt,
@@ -403,6 +487,13 @@ function postgresSnapshotRepository(db) {
403
487
  .where(and(eq(snapshots.matchId, matchId), eq(snapshots.turn, turn)));
404
488
  return firstOrNull(rows.map(toSnapshot));
405
489
  },
490
+ async getSummary(matchId, turn) {
491
+ const rows = await db
492
+ .select(snapshotSummaryColumns)
493
+ .from(snapshots)
494
+ .where(and(eq(snapshots.matchId, matchId), eq(snapshots.turn, turn)));
495
+ return firstOrNull(rows.map(toSnapshotSummary));
496
+ },
406
497
  async getLatest(matchId) {
407
498
  const rows = await db
408
499
  .select()
@@ -414,16 +505,7 @@ function postgresSnapshotRepository(db) {
414
505
  },
415
506
  async getLatestSummary(matchId) {
416
507
  const rows = await db
417
- .select({
418
- matchId: snapshots.matchId,
419
- turn: snapshots.turn,
420
- formatVersion: snapshots.formatVersion,
421
- protocolVersion: snapshots.protocolVersion,
422
- sessionVersion: snapshots.sessionVersion,
423
- stateHash: snapshots.stateHash,
424
- uploadedByPlayerId: snapshots.uploadedByPlayerId,
425
- uploadedAt: snapshots.uploadedAt,
426
- })
508
+ .select(snapshotSummaryColumns)
427
509
  .from(snapshots)
428
510
  .where(eq(snapshots.matchId, matchId))
429
511
  .orderBy(desc(snapshots.turn))
@@ -449,135 +531,4 @@ function postgresSnapshotRepository(db) {
449
531
  },
450
532
  };
451
533
  }
452
- function postgresTakeoverRepository(db) {
453
- const { takeoverPrompts, takeoverVotes } = schema;
454
- return {
455
- async openPrompt(matchId, playerId, turn, openedAt) {
456
- const rows = await db
457
- .insert(takeoverPrompts)
458
- .values({ matchId, playerId, turn, openedAt })
459
- .onConflictDoNothing()
460
- .returning({ playerId: takeoverPrompts.playerId });
461
- return rows.length === 1;
462
- },
463
- async closePrompt(matchId, playerId) {
464
- // Votes first: a death between the two leaves an open prompt with no votes, which is the
465
- // safe state, rather than votes that a later prompt for the same seat would inherit.
466
- await db
467
- .delete(takeoverVotes)
468
- .where(and(eq(takeoverVotes.matchId, matchId), eq(takeoverVotes.targetPlayerId, playerId)));
469
- await db
470
- .delete(takeoverPrompts)
471
- .where(and(eq(takeoverPrompts.matchId, matchId), eq(takeoverPrompts.playerId, playerId)));
472
- },
473
- async hasOpenPrompts(matchId) {
474
- const rows = await db
475
- .select({ playerId: takeoverPrompts.playerId })
476
- .from(takeoverPrompts)
477
- .where(eq(takeoverPrompts.matchId, matchId))
478
- .limit(1);
479
- return rows.length > 0;
480
- },
481
- async listOpenPrompts(matchId) {
482
- const rows = await db
483
- .select({ playerId: takeoverPrompts.playerId })
484
- .from(takeoverPrompts)
485
- .where(eq(takeoverPrompts.matchId, matchId))
486
- .orderBy(asc(takeoverPrompts.playerId));
487
- return rows.map((row) => row.playerId);
488
- },
489
- /**
490
- * An insert fed by a select over the prompt row, so "the prompt is open" is tested by the same
491
- * statement that writes the vote; the conflict clause makes it a replacement of the voter's
492
- * earlier choice.
493
- */
494
- async castVote(matchId, targetPlayerId, voterPlayerId, decision, castAt) {
495
- const rows = await db
496
- .insert(takeoverVotes)
497
- .select(db
498
- .select({
499
- matchId: sql `${matchId}`.as('match_id'),
500
- targetPlayerId: sql `${targetPlayerId}`.as('target_player_id'),
501
- voterPlayerId: sql `${voterPlayerId}`.as('voter_player_id'),
502
- decision: sql `${decision}`.as('decision'),
503
- castAt: sql `${castAt}`.as('cast_at'),
504
- })
505
- .from(takeoverPrompts)
506
- .where(and(eq(takeoverPrompts.matchId, matchId), eq(takeoverPrompts.playerId, targetPlayerId))))
507
- .onConflictDoUpdate({
508
- target: [
509
- takeoverVotes.matchId,
510
- takeoverVotes.targetPlayerId,
511
- takeoverVotes.voterPlayerId,
512
- ],
513
- set: { decision, castAt },
514
- })
515
- .returning({ voterPlayerId: takeoverVotes.voterPlayerId });
516
- return rows.length === 1;
517
- },
518
- async listVotes(matchId, targetPlayerId) {
519
- const rows = await db
520
- .select()
521
- .from(takeoverVotes)
522
- .where(and(eq(takeoverVotes.matchId, matchId), eq(takeoverVotes.targetPlayerId, targetPlayerId)))
523
- .orderBy(asc(takeoverVotes.voterPlayerId));
524
- return rows.map(toTakeoverVote);
525
- },
526
- };
527
- }
528
- function postgresEventRepository(db) {
529
- const { matchEvents } = schema;
530
- return {
531
- /**
532
- * The sequence number comes from the log itself inside the insert, so the allocation cannot be
533
- * separated from the write: a committed `seq` therefore implies every lower one is committed,
534
- * which is the invariant a stream cursor relies on. Concurrent appends collide on the primary
535
- * key and the loser simply re-reads the maximum.
536
- */
537
- async append(event) {
538
- const nextSeq = sql `(select coalesce(max(${matchEvents.seq}), 0) + 1 from ${matchEvents} where ${eq(matchEvents.matchId, event.matchId)})`;
539
- for (let attempt = 1; attempt <= APPEND_ATTEMPTS; attempt += 1) {
540
- try {
541
- const rows = await db
542
- .insert(matchEvents)
543
- .values({
544
- matchId: event.matchId,
545
- seq: nextSeq,
546
- type: event.type,
547
- payload: event.payload,
548
- createdAt: new Date(event.createdAt),
549
- })
550
- .returning({ seq: matchEvents.seq });
551
- const row = rows[0];
552
- if (!row)
553
- throw new Error(`append to ${event.matchId} reported no row`);
554
- return { ...event, seq: row.seq };
555
- }
556
- catch (error) {
557
- if (!isUniqueViolation(error) || attempt === APPEND_ATTEMPTS)
558
- throw error;
559
- }
560
- }
561
- throw new Error(`could not append an event for ${event.matchId}`);
562
- },
563
- async listAfter(matchId, afterSeq, limit) {
564
- const rows = await db
565
- .select()
566
- .from(matchEvents)
567
- .where(and(eq(matchEvents.matchId, matchId), sql `${matchEvents.seq} > ${afterSeq}`))
568
- .orderBy(asc(matchEvents.seq))
569
- .limit(limit);
570
- return rows.map(toEvent);
571
- },
572
- async lastSeq(matchId) {
573
- const rows = await db
574
- .select({ seq: matchEvents.seq })
575
- .from(matchEvents)
576
- .where(eq(matchEvents.matchId, matchId))
577
- .orderBy(desc(matchEvents.seq))
578
- .limit(1);
579
- return rows[0]?.seq ?? 0;
580
- },
581
- };
582
- }
583
534
  //# sourceMappingURL=repositories.js.map