@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 { 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
|
-
|
|
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(
|
|
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
|
-
|
|
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),
|
|
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
|
-
.
|
|
133
|
-
.
|
|
134
|
-
.
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
377
|
-
.
|
|
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
|