solid-objects 0.17.5 → 0.17.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +10 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +6 -1
- package/dist/repository.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +3 -1
- package/dist/worker.js.map +1 -1
- package/docs/agents.md +5 -0
- package/docs/guides/offline-first.md +283 -0
- package/docs/guides/persistent-rooms.md +392 -0
- package/docs/guides/race-conditions.md +362 -0
- package/docs/parity.md +10 -2
- package/examples/guides/offline-first/inspection-worker.js +59 -0
- package/examples/guides/offline-first/memory-outbox.js +16 -0
- package/examples/guides/offline-first/page.js +30 -0
- package/examples/guides/offline-first/server-inspection.ts +17 -0
- package/examples/guides/offline-first/sync-route.ts +37 -0
- package/examples/guides/persistent-rooms/memory-room.ts +44 -0
- package/examples/guides/persistent-rooms/reconnect.ts +18 -0
- package/examples/guides/persistent-rooms/room-runtime.ts +49 -0
- package/examples/guides/persistent-rooms/sql-room.ts +80 -0
- package/examples/guides/persistent-rooms/turn-room.ts +85 -0
- package/examples/guides/race-conditions/atomic-hold.ts +28 -0
- package/examples/guides/race-conditions/event-runtime.ts +48 -0
- package/examples/guides/race-conditions/event-seats.ts +76 -0
- package/examples/guides/race-conditions/lost-update.ts +37 -0
- package/examples/guides/race-conditions/seat-inventory.ts +36 -0
- package/package.json +3 -3
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
|
|
2
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
3
|
+
import { TurnRoom } from "./turn-room.js"
|
|
4
|
+
|
|
5
|
+
export type RoomCaller = { playerId: string }
|
|
6
|
+
|
|
7
|
+
const PLAYER_OPERATIONS = new Set(["join", "start", "play"])
|
|
8
|
+
|
|
9
|
+
export function authorizeRoomMessage({
|
|
10
|
+
actorType,
|
|
11
|
+
operation,
|
|
12
|
+
arguments: argumentsValue,
|
|
13
|
+
authorizationContext,
|
|
14
|
+
}: {
|
|
15
|
+
actorType: string
|
|
16
|
+
operation: string
|
|
17
|
+
arguments: Record<string, unknown>
|
|
18
|
+
authorizationContext: unknown
|
|
19
|
+
}): boolean {
|
|
20
|
+
if (actorType !== TurnRoom.actorType) return false
|
|
21
|
+
if (!PLAYER_OPERATIONS.has(operation)) return false
|
|
22
|
+
if (!isRoomCaller(authorizationContext)) return false
|
|
23
|
+
return argumentsValue.playerId === authorizationContext.playerId
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function authorizeRoomQuery({
|
|
27
|
+
actorType,
|
|
28
|
+
authorizationContext,
|
|
29
|
+
}: {
|
|
30
|
+
actorType: string
|
|
31
|
+
authorizationContext: unknown
|
|
32
|
+
}): boolean {
|
|
33
|
+
return actorType === TurnRoom.actorType && isRoomCaller(authorizationContext)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function roomRuntime({ path }: { path: string }): SolidObjectsRuntime {
|
|
37
|
+
const runtime = createRuntime({
|
|
38
|
+
database: sqlite({ path }),
|
|
39
|
+
authorizeMessage: authorizeRoomMessage,
|
|
40
|
+
authorizeQuery: authorizeRoomQuery,
|
|
41
|
+
})
|
|
42
|
+
runtime.register(TurnRoom)
|
|
43
|
+
return runtime
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRoomCaller(value: unknown): value is RoomCaller {
|
|
47
|
+
if (typeof value !== "object" || value === null) return false
|
|
48
|
+
return "playerId" in value && typeof value.playerId === "string"
|
|
49
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite"
|
|
2
|
+
|
|
3
|
+
export type RoomState = { players: string[]; turnNumber: number }
|
|
4
|
+
|
|
5
|
+
export function openRoomStore({ path }: { path: string }): DatabaseSync {
|
|
6
|
+
const database = new DatabaseSync(path)
|
|
7
|
+
database.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS rooms (
|
|
9
|
+
room_id TEXT PRIMARY KEY,
|
|
10
|
+
state TEXT NOT NULL,
|
|
11
|
+
version INTEGER NOT NULL,
|
|
12
|
+
turn_deadline INTEGER
|
|
13
|
+
)
|
|
14
|
+
`)
|
|
15
|
+
return database
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveRoom({
|
|
19
|
+
database,
|
|
20
|
+
roomId,
|
|
21
|
+
state,
|
|
22
|
+
expectedVersion,
|
|
23
|
+
turnDeadline,
|
|
24
|
+
}: {
|
|
25
|
+
database: DatabaseSync
|
|
26
|
+
roomId: string
|
|
27
|
+
state: RoomState
|
|
28
|
+
expectedVersion: number
|
|
29
|
+
turnDeadline: number | null
|
|
30
|
+
}): { saved: boolean } {
|
|
31
|
+
if (expectedVersion === 0) {
|
|
32
|
+
const inserted = database
|
|
33
|
+
.prepare(
|
|
34
|
+
"INSERT OR IGNORE INTO rooms (room_id, state, version, turn_deadline) VALUES (?, ?, 1, ?)",
|
|
35
|
+
)
|
|
36
|
+
.run(roomId, JSON.stringify(state), turnDeadline)
|
|
37
|
+
return { saved: inserted.changes === 1 }
|
|
38
|
+
}
|
|
39
|
+
const updated = database
|
|
40
|
+
.prepare(
|
|
41
|
+
"UPDATE rooms SET state = ?, version = version + 1, turn_deadline = ? WHERE room_id = ? AND version = ?",
|
|
42
|
+
)
|
|
43
|
+
.run(JSON.stringify(state), turnDeadline, roomId, expectedVersion)
|
|
44
|
+
return { saved: updated.changes === 1 }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadRoom({
|
|
48
|
+
database,
|
|
49
|
+
roomId,
|
|
50
|
+
}: {
|
|
51
|
+
database: DatabaseSync
|
|
52
|
+
roomId: string
|
|
53
|
+
}): { state: RoomState; version: number } | undefined {
|
|
54
|
+
const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as
|
|
55
|
+
{ state: string; version: number } | undefined
|
|
56
|
+
if (!row) return undefined
|
|
57
|
+
const state: unknown = JSON.parse(row.state)
|
|
58
|
+
if (!isRoomState(state)) throw new Error(`stored room ${roomId} has an unexpected shape`)
|
|
59
|
+
return { state, version: row.version }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function dueTurnDeadlines({
|
|
63
|
+
database,
|
|
64
|
+
now,
|
|
65
|
+
}: {
|
|
66
|
+
database: DatabaseSync
|
|
67
|
+
now: number
|
|
68
|
+
}): string[] {
|
|
69
|
+
const rows = database
|
|
70
|
+
.prepare("SELECT room_id FROM rooms WHERE turn_deadline <= ? ORDER BY turn_deadline")
|
|
71
|
+
.all(now) as { room_id: string }[]
|
|
72
|
+
return rows.map((row) => row.room_id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRoomState(value: unknown): value is RoomState {
|
|
76
|
+
if (typeof value !== "object" || value === null) return false
|
|
77
|
+
if (!("players" in value) || !Array.isArray(value.players)) return false
|
|
78
|
+
if (!value.players.every((player) => typeof player === "string")) return false
|
|
79
|
+
return "turnNumber" in value && Number.isSafeInteger(value.turnNumber)
|
|
80
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Actor } from "solid-objects"
|
|
2
|
+
|
|
3
|
+
export const TURN_MILLISECONDS = 60_000
|
|
4
|
+
|
|
5
|
+
const MAXIMUM_PLAYERS = 4
|
|
6
|
+
|
|
7
|
+
type Move = { playerId: string; move: string; turnNumber: number }
|
|
8
|
+
|
|
9
|
+
export class TurnRoom extends Actor {
|
|
10
|
+
static override readonly actorType = "TurnRoom"
|
|
11
|
+
|
|
12
|
+
players: string[] = []
|
|
13
|
+
started = false
|
|
14
|
+
turnNumber = 0
|
|
15
|
+
moves: Move[] = []
|
|
16
|
+
skippedTurns: number[] = []
|
|
17
|
+
revision = 0
|
|
18
|
+
|
|
19
|
+
get currentPlayer(): string | null {
|
|
20
|
+
if (!this.started) return null
|
|
21
|
+
return this.players[this.turnNumber % this.players.length] ?? null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
join({ playerId }: { playerId: string }): { players: string[] } {
|
|
25
|
+
if (this.players.includes(playerId)) return { players: this.players }
|
|
26
|
+
if (this.started) this.reject("game_started", { message: "The game already started" })
|
|
27
|
+
if (this.players.length === MAXIMUM_PLAYERS) {
|
|
28
|
+
this.reject("room_full", { message: "The room is full" })
|
|
29
|
+
}
|
|
30
|
+
this.players = [...this.players, playerId]
|
|
31
|
+
this.revision += 1
|
|
32
|
+
return { players: this.players }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start({ playerId }: { playerId: string }): { turnNumber: number } {
|
|
36
|
+
if (this.started) return { turnNumber: this.turnNumber }
|
|
37
|
+
if (!this.players.includes(playerId)) {
|
|
38
|
+
this.reject("not_in_room", { message: "Only a player in the room can start the game" })
|
|
39
|
+
}
|
|
40
|
+
if (this.players.length < 2) {
|
|
41
|
+
this.reject("not_enough_players", { message: "The game needs two players" })
|
|
42
|
+
}
|
|
43
|
+
this.started = true
|
|
44
|
+
this.revision += 1
|
|
45
|
+
this.#armTurnTimer()
|
|
46
|
+
return { turnNumber: this.turnNumber }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
play({ playerId, move, turnNumber }: { playerId: string; move: string; turnNumber: number }): {
|
|
50
|
+
turnNumber: number
|
|
51
|
+
} {
|
|
52
|
+
if (!this.started) this.reject("game_not_started", { message: "The game has not started" })
|
|
53
|
+
if (turnNumber !== this.turnNumber) {
|
|
54
|
+
this.reject("stale_turn", {
|
|
55
|
+
message: "That turn already ended",
|
|
56
|
+
details: { turnNumber: this.turnNumber },
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
if (playerId !== this.currentPlayer) {
|
|
60
|
+
this.reject("not_your_turn", { message: "It is not your turn" })
|
|
61
|
+
}
|
|
62
|
+
this.moves = [...this.moves, { playerId, move, turnNumber }]
|
|
63
|
+
this.#advanceTurn()
|
|
64
|
+
return { turnNumber: this.turnNumber }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
turnTimeout({ turnNumber }: { turnNumber: number }): { skipped: boolean } {
|
|
68
|
+
if (turnNumber !== this.turnNumber) return { skipped: false }
|
|
69
|
+
this.skippedTurns = [...this.skippedTurns, turnNumber]
|
|
70
|
+
this.#advanceTurn()
|
|
71
|
+
return { skipped: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#advanceTurn(): void {
|
|
75
|
+
this.turnNumber += 1
|
|
76
|
+
this.revision += 1
|
|
77
|
+
this.#armTurnTimer()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#armTurnTimer(): void {
|
|
81
|
+
this.schedule({ at: new Date(Date.now() + TURN_MILLISECONDS) }).turnTimeout({
|
|
82
|
+
turnNumber: this.turnNumber,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
2
|
+
|
|
3
|
+
export function holdSeatAtomically({
|
|
4
|
+
database,
|
|
5
|
+
eventId,
|
|
6
|
+
buyer,
|
|
7
|
+
}: {
|
|
8
|
+
database: DatabaseSync
|
|
9
|
+
eventId: string
|
|
10
|
+
buyer: string
|
|
11
|
+
}): { held: boolean } {
|
|
12
|
+
database.exec("BEGIN IMMEDIATE")
|
|
13
|
+
try {
|
|
14
|
+
const result = database
|
|
15
|
+
.prepare("UPDATE seats SET available = available - 1 WHERE event_id = ? AND available > 0")
|
|
16
|
+
.run(eventId)
|
|
17
|
+
if (result.changes === 0) {
|
|
18
|
+
database.exec("ROLLBACK")
|
|
19
|
+
return { held: false }
|
|
20
|
+
}
|
|
21
|
+
database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer)
|
|
22
|
+
database.exec("COMMIT")
|
|
23
|
+
return { held: true }
|
|
24
|
+
} catch (error) {
|
|
25
|
+
database.exec("ROLLBACK")
|
|
26
|
+
throw error
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createRuntime, type SolidObjectsRuntime } from "solid-objects"
|
|
2
|
+
import { sqlite } from "solid-objects/database/sqlite"
|
|
3
|
+
import { EventSeats, type EventCaller } from "./event-seats.js"
|
|
4
|
+
|
|
5
|
+
const SYSTEM_OPERATIONS = new Set(["confirm", "expire"])
|
|
6
|
+
|
|
7
|
+
export function authorizeEventMessage({
|
|
8
|
+
actorType,
|
|
9
|
+
operation,
|
|
10
|
+
authorizationContext,
|
|
11
|
+
}: {
|
|
12
|
+
actorType: string
|
|
13
|
+
operation: string
|
|
14
|
+
authorizationContext: unknown
|
|
15
|
+
}): boolean {
|
|
16
|
+
if (actorType !== EventSeats.actorType) return false
|
|
17
|
+
if (!isEventCaller(authorizationContext)) return false
|
|
18
|
+
if (operation === "setCapacity") return authorizationContext.role === "organizer"
|
|
19
|
+
if (SYSTEM_OPERATIONS.has(operation)) return authorizationContext.role === "system"
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function authorizeEventQuery({
|
|
24
|
+
actorType,
|
|
25
|
+
authorizationContext,
|
|
26
|
+
}: {
|
|
27
|
+
actorType: string
|
|
28
|
+
authorizationContext: unknown
|
|
29
|
+
}): boolean {
|
|
30
|
+
return actorType === EventSeats.actorType && isEventCaller(authorizationContext)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function eventRuntime({ path }: { path: string }): SolidObjectsRuntime {
|
|
34
|
+
const runtime = createRuntime({
|
|
35
|
+
database: sqlite({ path }),
|
|
36
|
+
authorizeMessage: authorizeEventMessage,
|
|
37
|
+
authorizeQuery: authorizeEventQuery,
|
|
38
|
+
})
|
|
39
|
+
runtime.register(EventSeats)
|
|
40
|
+
return runtime
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isEventCaller(value: unknown): value is EventCaller {
|
|
44
|
+
if (typeof value !== "object" || value === null) return false
|
|
45
|
+
if (!("userId" in value) || typeof value.userId !== "string") return false
|
|
46
|
+
if (!("role" in value)) return false
|
|
47
|
+
return value.role === "buyer" || value.role === "organizer" || value.role === "system"
|
|
48
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Actor } from "solid-objects"
|
|
2
|
+
|
|
3
|
+
export const HOLD_MILLISECONDS = 10 * 60 * 1000
|
|
4
|
+
|
|
5
|
+
export type EventCaller = { userId: string; role: "buyer" | "organizer" | "system" }
|
|
6
|
+
|
|
7
|
+
type Hold = { buyer: string; expiresAt: number }
|
|
8
|
+
|
|
9
|
+
export class EventSeats extends Actor {
|
|
10
|
+
static override readonly actorType = "EventSeats"
|
|
11
|
+
|
|
12
|
+
capacity = 0
|
|
13
|
+
revision = 0
|
|
14
|
+
holds: Record<string, Hold> = {}
|
|
15
|
+
sold: Record<string, string> = {}
|
|
16
|
+
|
|
17
|
+
get available(): number {
|
|
18
|
+
return this.capacity - Object.keys(this.holds).length - Object.keys(this.sold).length
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): {
|
|
22
|
+
revision: number
|
|
23
|
+
} {
|
|
24
|
+
if (!Number.isSafeInteger(capacity) || capacity < 0) {
|
|
25
|
+
this.reject("invalid_capacity", { message: "Capacity must be a whole number of seats" })
|
|
26
|
+
}
|
|
27
|
+
if (expectedRevision !== this.revision) {
|
|
28
|
+
this.reject("stale_revision", {
|
|
29
|
+
message: "The event changed after you loaded it",
|
|
30
|
+
details: { revision: this.revision },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
if (capacity < this.capacity - this.available) {
|
|
34
|
+
this.reject("capacity_below_sold", { message: "Holds and sales already use more seats" })
|
|
35
|
+
}
|
|
36
|
+
this.capacity = capacity
|
|
37
|
+
this.revision += 1
|
|
38
|
+
return { revision: this.revision }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hold({ holdId, buyer }: { holdId: string; buyer: string }): { held: boolean; available: number } {
|
|
42
|
+
if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) {
|
|
43
|
+
return { held: true, available: this.available }
|
|
44
|
+
}
|
|
45
|
+
if (this.available <= 0) return { held: false, available: 0 }
|
|
46
|
+
|
|
47
|
+
const expiresAt = Date.now() + HOLD_MILLISECONDS
|
|
48
|
+
this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } }
|
|
49
|
+
this.schedule({ at: new Date(expiresAt), key: holdId }).expire({ holdId })
|
|
50
|
+
return { held: true, available: this.available }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
confirm({ holdId }: { holdId: string }): { confirmed: boolean } {
|
|
54
|
+
if (Object.hasOwn(this.sold, holdId)) return { confirmed: true }
|
|
55
|
+
const hold = this.holds[holdId]
|
|
56
|
+
if (!hold) {
|
|
57
|
+
this.reject("hold_expired", { message: "The hold expired before the payment arrived" })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.holds = withoutKey(this.holds, holdId)
|
|
61
|
+
this.sold = { ...this.sold, [holdId]: hold.buyer }
|
|
62
|
+
this.unschedule("expire", { key: holdId })
|
|
63
|
+
return { confirmed: true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expire({ holdId }: { holdId: string }): { expired: boolean } {
|
|
67
|
+
if (!Object.hasOwn(this.holds, holdId)) return { expired: false }
|
|
68
|
+
|
|
69
|
+
this.holds = withoutKey(this.holds, holdId)
|
|
70
|
+
return { expired: true }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function withoutKey<Value>(record: Record<string, Value>, key: string): Record<string, Value> {
|
|
75
|
+
return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key))
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
2
|
+
|
|
3
|
+
export async function holdSeatWithRace({
|
|
4
|
+
database,
|
|
5
|
+
eventId,
|
|
6
|
+
buyer,
|
|
7
|
+
checkBuyer,
|
|
8
|
+
}: {
|
|
9
|
+
database: DatabaseSync
|
|
10
|
+
eventId: string
|
|
11
|
+
buyer: string
|
|
12
|
+
checkBuyer: () => Promise<void>
|
|
13
|
+
}): Promise<{ held: boolean }> {
|
|
14
|
+
const row = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as {
|
|
15
|
+
available: number
|
|
16
|
+
}
|
|
17
|
+
if (row.available === 0) return { held: false }
|
|
18
|
+
|
|
19
|
+
await checkBuyer()
|
|
20
|
+
|
|
21
|
+
database
|
|
22
|
+
.prepare("UPDATE seats SET available = ? WHERE event_id = ?")
|
|
23
|
+
.run(row.available - 1, eventId)
|
|
24
|
+
database.prepare("INSERT INTO holds (event_id, buyer) VALUES (?, ?)").run(eventId, buyer)
|
|
25
|
+
return { held: true }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createLocalMutex(): { run<Result>(task: () => Promise<Result>): Promise<Result> } {
|
|
29
|
+
let tail: Promise<unknown> = Promise.resolve()
|
|
30
|
+
return {
|
|
31
|
+
run<Result>(task: () => Promise<Result>): Promise<Result> {
|
|
32
|
+
const result = tail.then(task)
|
|
33
|
+
tail = result.catch(() => undefined)
|
|
34
|
+
return result
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite"
|
|
2
|
+
|
|
3
|
+
export function openSeatInventory({
|
|
4
|
+
path,
|
|
5
|
+
eventId,
|
|
6
|
+
available,
|
|
7
|
+
}: {
|
|
8
|
+
path: string
|
|
9
|
+
eventId: string
|
|
10
|
+
available: number
|
|
11
|
+
}): DatabaseSync {
|
|
12
|
+
const database = new DatabaseSync(path)
|
|
13
|
+
database.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS seats (event_id TEXT PRIMARY KEY, available INTEGER NOT NULL);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS holds (event_id TEXT NOT NULL, buyer TEXT NOT NULL);
|
|
16
|
+
`)
|
|
17
|
+
database
|
|
18
|
+
.prepare("INSERT OR IGNORE INTO seats (event_id, available) VALUES (?, ?)")
|
|
19
|
+
.run(eventId, available)
|
|
20
|
+
return database
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function seatCounts({ database, eventId }: { database: DatabaseSync; eventId: string }): {
|
|
24
|
+
available: number
|
|
25
|
+
holds: number
|
|
26
|
+
} {
|
|
27
|
+
const seats = database.prepare("SELECT available FROM seats WHERE event_id = ?").get(eventId) as {
|
|
28
|
+
available: number
|
|
29
|
+
}
|
|
30
|
+
const holds = database
|
|
31
|
+
.prepare("SELECT COUNT(*) AS count FROM holds WHERE event_id = ?")
|
|
32
|
+
.get(eventId) as {
|
|
33
|
+
count: number
|
|
34
|
+
}
|
|
35
|
+
return { available: seats.available, holds: holds.count }
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid-objects",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.7",
|
|
4
4
|
"description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -118,8 +118,8 @@
|
|
|
118
118
|
"test": "vitest run",
|
|
119
119
|
"test:browser": "pnpm run build && playwright test",
|
|
120
120
|
"test:coverage": "vitest run --coverage",
|
|
121
|
-
"test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
|
|
122
|
-
"test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
|
|
121
|
+
"test:postgresql": "vitest run test/postgresql.test.ts test/activation-lease.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
|
|
122
|
+
"test:mysql": "vitest run test/mysql.test.ts test/activation-lease.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
|
|
123
123
|
"test:package": "node scripts/release-artifact-smoke.mjs",
|
|
124
124
|
"test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
|
|
125
125
|
"test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",
|