solid-objects 0.17.5 → 0.17.6

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.
@@ -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.5",
3
+ "version": "0.17.6",
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",