Skip to content

Commit 4268a54

Browse files
committed
fix: address review of the problem guides
- EventSeats rejects a capacity that is not a whole, non-negative number with invalid_capacity, and hold stops at available <= 0. A capacity of 1.5 let holds drive availability below zero. - The sync route answers 422 for UnknownOperation, so a device stops retrying a queued write for an operation that a server deploy removed. - The SQL room store uses a RoomState type and guards the stored JSON; loadRoom throws for an unexpected shape instead of returning it. - The cross-process race test kills its child process after each test, so a failed run leaves no waiting process. Each fix started from a failing test. The guides embed the new example files and describe the new behavior.
1 parent b9bff47 commit 4268a54

9 files changed

Lines changed: 100 additions & 19 deletions

File tree

‎docs/guides/offline-first.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ import {
189189
IdempotencyConflict,
190190
InvalidPayload,
191191
receiveTransmitEnvelope,
192+
UnknownOperation,
192193
type SolidObjectsRuntime,
193194
type TransmitEnvelope,
194195
} from "solid-objects"
@@ -209,7 +210,11 @@ export async function handleInspectionSync({
209210
await receiveTransmitEnvelope({ runtime, envelope })
210211
return Response.json({})
211212
} catch (error) {
212-
if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) {
213+
if (
214+
error instanceof InvalidPayload ||
215+
error instanceof IdempotencyConflict ||
216+
error instanceof UnknownOperation
217+
) {
213218
return new Response(null, { status: 422 })
214219
}
215220
throw error
@@ -223,7 +228,7 @@ Delivery through `receiveTransmitEnvelope` skips `authorizeMessage`. The route m
223228
224229
The example gives those checks to `canWrite`. If that callback denies the request, the route answers 403 and does not enqueue the write. The browser policy cannot replace these server checks.
225230
226-
The route answers 422 for `InvalidPayload` and `IdempotencyConflict`. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply.
231+
The route answers 422 for `InvalidPayload`, `IdempotencyConflict`, and `UnknownOperation`. An `UnknownOperation` occurs when a device queued a write for an operation that a later server deploy removed. The browser converts that response to `NonRetryableError` and stops retries for that write. This response prevents repeated attempts for an envelope that cannot apply.
227232
228233
A Rails server can receive the same envelopes with `SolidObjects::Transmission.receive`. That method also skips message authorization. Authenticate the device before the call. Check its permission to write the actor.
229234
@@ -265,6 +270,7 @@ The [route tests](../../test/guide-offline-first.test.ts) check these cases:
265270
- An authorized request receives 200 and adds the expected finding.
266271
- A repeated envelope receives 200 and leaves one finding.
267272
- An envelope with an empty operation receives 422.
273+
- An envelope for an operation that the server removed receives 422.
268274
269275
These tests do not check every browser engine or every interruption during delivery. They also do not test `IdempotencyConflict` or the browser response to 422.
270276

‎docs/guides/persistent-rooms.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ The test models a restart with two instances of `createMemoryRooms`. It starts a
6060
```typescript
6161
import { DatabaseSync } from "node:sqlite"
6262

63+
export type RoomState = { players: string[]; turnNumber: number }
64+
6365
export function openRoomStore({ path }: { path: string }): DatabaseSync {
6466
const database = new DatabaseSync(path)
6567
database.exec(`
@@ -82,7 +84,7 @@ export function saveRoom({
8284
}: {
8385
database: DatabaseSync
8486
roomId: string
85-
state: unknown
87+
state: RoomState
8688
expectedVersion: number
8789
turnDeadline: number | null
8890
}): { saved: boolean } {
@@ -108,11 +110,13 @@ export function loadRoom({
108110
}: {
109111
database: DatabaseSync
110112
roomId: string
111-
}): { state: unknown; version: number } | undefined {
113+
}): { state: RoomState; version: number } | undefined {
112114
const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as
113115
{ state: string; version: number } | undefined
114116
if (!row) return undefined
115-
return { state: JSON.parse(row.state), version: row.version }
117+
const state: unknown = JSON.parse(row.state)
118+
if (!isRoomState(state)) throw new Error(`stored room ${roomId} has an unexpected shape`)
119+
return { state, version: row.version }
116120
}
117121

118122
export function dueTurnDeadlines({
@@ -127,9 +131,16 @@ export function dueTurnDeadlines({
127131
.all(now) as { room_id: string }[]
128132
return rows.map((row) => row.room_id)
129133
}
134+
135+
function isRoomState(value: unknown): value is RoomState {
136+
if (typeof value !== "object" || value === null) return false
137+
if (!("players" in value) || !Array.isArray(value.players)) return false
138+
if (!value.players.every((player) => typeof player === "string")) return false
139+
return "turnNumber" in value && Number.isSafeInteger(value.turnNumber)
140+
}
130141
```
131142

132-
The SQL design stores each room as JSON in a row with a `version` column and a turn deadline. `loadRoom` returns the state and its version. The caller passes that version to `saveRoom` as `expectedVersion`.
143+
The SQL design stores each room as JSON in a row with a `version` column and a turn deadline. `loadRoom` returns the state and its version. It checks the stored JSON against `RoomState` and throws when the shape is unexpected, so the caller never uses unchecked data. The caller passes that version to `saveRoom` as `expectedVersion`.
133144

134145
`saveRoom` updates a row only when its version still matches. A successful update also increases the version. A write from an old version returns `{ saved: false }`. The caller then reloads the room and tries again. An old copy of the state cannot replace a newer copy.
135146

@@ -356,7 +367,7 @@ In tests, `runtime.testing.runDueReminders({ now })` finds due reminders at the
356367
The [tests for persistent rooms](../../test/guide-persistent-rooms.test.ts) check these cases in this order:
357368

358369
1. A new instance of the memory design has no room and no timer after a simulated restart.
359-
2. The SQL room survives a database reopen. A write from an old version fails, and the sweep finds the due room.
370+
2. The SQL room survives a database reopen. A write from an old version fails, and the sweep finds the due room. A stored room with an unexpected shape fails to load.
360371
3. Two submissions for one turn produce one accepted move and one `stale_turn` rejection.
361372
4. A move from the wrong player rejects with `not_your_turn`.
362373
5. A caller cannot submit a move for another player.

‎docs/guides/race-conditions.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ export class EventSeats extends Actor {
184184
setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): {
185185
revision: number
186186
} {
187+
if (!Number.isSafeInteger(capacity) || capacity < 0) {
188+
this.reject("invalid_capacity", { message: "Capacity must be a whole number of seats" })
189+
}
187190
if (expectedRevision !== this.revision) {
188191
this.reject("stale_revision", {
189192
message: "The event changed after you loaded it",
@@ -202,7 +205,7 @@ export class EventSeats extends Actor {
202205
if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) {
203206
return { held: true, available: this.available }
204207
}
205-
if (this.available === 0) return { held: false, available: 0 }
208+
if (this.available <= 0) return { held: false, available: 0 }
206209

207210
const expiresAt = Date.now() + HOLD_MILLISECONDS
208211
this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } }
@@ -252,7 +255,7 @@ Calls for one event run one at a time, in order, across processes. Different eve
252255

253256
An old form can overwrite newer data even when calls run in order. The calls run in order, but the second call still carries old data. The actor needs a rule that detects this conflict.
254257

255-
`setCapacity` takes `expectedRevision` and compares it with `revision`. A mismatch rejects the change with `stale_revision`. An accepted capacity change increases the revision.
258+
`setCapacity` takes `expectedRevision` and compares it with `revision`. A mismatch rejects the change with `stale_revision`. An accepted capacity change increases the revision. A capacity that is not a whole, non-negative number rejects with `invalid_capacity`, so a fractional capacity cannot leave a part of a seat to hold.
256259

257260
The test accepts a change from 10 seats to 12 seats. An old form then requests eight seats with the previous revision. The actor rejects that request and keeps the capacity at 12.
258261

@@ -337,6 +340,7 @@ The [test file](../../test/guide-race-conditions.test.ts) checks these outcomes:
337340
- A late expiry after a confirmation changes nothing.
338341
- A confirmation after expiry rejects with `hold_expired`.
339342
- A capacity change from a stale form rejects with `stale_revision`.
343+
- A fractional or negative capacity rejects with `invalid_capacity`.
340344
- A buyer cannot change capacity.
341345

342346
The assertions check both results and stored state. They show the behavior of these examples and their conflict cases.

‎examples/guides/offline-first/sync-route.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
IdempotencyConflict,
33
InvalidPayload,
44
receiveTransmitEnvelope,
5+
UnknownOperation,
56
type SolidObjectsRuntime,
67
type TransmitEnvelope,
78
} from "solid-objects"
@@ -22,7 +23,11 @@ export async function handleInspectionSync({
2223
await receiveTransmitEnvelope({ runtime, envelope })
2324
return Response.json({})
2425
} catch (error) {
25-
if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) {
26+
if (
27+
error instanceof InvalidPayload ||
28+
error instanceof IdempotencyConflict ||
29+
error instanceof UnknownOperation
30+
) {
2631
return new Response(null, { status: 422 })
2732
}
2833
throw error

‎examples/guides/persistent-rooms/sql-room.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { DatabaseSync } from "node:sqlite"
22

3+
export type RoomState = { players: string[]; turnNumber: number }
4+
35
export function openRoomStore({ path }: { path: string }): DatabaseSync {
46
const database = new DatabaseSync(path)
57
database.exec(`
@@ -22,7 +24,7 @@ export function saveRoom({
2224
}: {
2325
database: DatabaseSync
2426
roomId: string
25-
state: unknown
27+
state: RoomState
2628
expectedVersion: number
2729
turnDeadline: number | null
2830
}): { saved: boolean } {
@@ -48,11 +50,13 @@ export function loadRoom({
4850
}: {
4951
database: DatabaseSync
5052
roomId: string
51-
}): { state: unknown; version: number } | undefined {
53+
}): { state: RoomState; version: number } | undefined {
5254
const row = database.prepare("SELECT state, version FROM rooms WHERE room_id = ?").get(roomId) as
5355
{ state: string; version: number } | undefined
5456
if (!row) return undefined
55-
return { state: JSON.parse(row.state), version: row.version }
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 }
5660
}
5761

5862
export function dueTurnDeadlines({
@@ -67,3 +71,10 @@ export function dueTurnDeadlines({
6771
.all(now) as { room_id: string }[]
6872
return rows.map((row) => row.room_id)
6973
}
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+
}

‎examples/guides/race-conditions/event-seats.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export class EventSeats extends Actor {
2121
setCapacity({ capacity, expectedRevision }: { capacity: number; expectedRevision: number }): {
2222
revision: number
2323
} {
24+
if (!Number.isSafeInteger(capacity) || capacity < 0) {
25+
this.reject("invalid_capacity", { message: "Capacity must be a whole number of seats" })
26+
}
2427
if (expectedRevision !== this.revision) {
2528
this.reject("stale_revision", {
2629
message: "The event changed after you loaded it",
@@ -39,7 +42,7 @@ export class EventSeats extends Actor {
3942
if (Object.hasOwn(this.holds, holdId) || Object.hasOwn(this.sold, holdId)) {
4043
return { held: true, available: this.available }
4144
}
42-
if (this.available === 0) return { held: false, available: 0 }
45+
if (this.available <= 0) return { held: false, available: 0 }
4346

4447
const expiresAt = Date.now() + HOLD_MILLISECONDS
4548
this.holds = { ...this.holds, [holdId]: { buyer, expiresAt } }

‎test/guide-offline-first.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,17 @@ describe("the inspection sync route", () => {
113113

114114
expect(response.status).toBe(422)
115115
})
116+
117+
it("answers 422 for a write to an operation that the server removed", async () => {
118+
const runtime = await serverRuntime()
119+
const removed = { ...envelope, operation: "removedOperation" }
120+
121+
const response = await handleInspectionSync({
122+
request: syncRequest({ device: "tablet-7", body: removed }),
123+
runtime,
124+
canWrite,
125+
})
126+
127+
expect(response.status).toBe(422)
128+
})
116129
})

‎test/guide-persistent-rooms.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,21 @@ describe("the native fix with a version column", () => {
7474
saveRoom({
7575
database: beforeRestart,
7676
roomId: "table-1",
77-
state: { turn: 0 },
77+
state: { players: ["ada"], turnNumber: 0 },
7878
expectedVersion: 0,
7979
turnDeadline: 1_000,
8080
})
8181
beforeRestart.close()
8282

8383
const afterRestart = openRoomStore({ path })
8484
const loaded = loadRoom({ database: afterRestart, roomId: "table-1" })
85-
expect(loaded).toEqual({ state: { turn: 0 }, version: 1 })
85+
expect(loaded).toEqual({ state: { players: ["ada"], turnNumber: 0 }, version: 1 })
8686

8787
expect(
8888
saveRoom({
8989
database: afterRestart,
9090
roomId: "table-1",
91-
state: { turn: 1 },
91+
state: { players: ["ada"], turnNumber: 1 },
9292
expectedVersion: 1,
9393
turnDeadline: 2_000,
9494
}),
@@ -97,7 +97,7 @@ describe("the native fix with a version column", () => {
9797
saveRoom({
9898
database: afterRestart,
9999
roomId: "table-1",
100-
state: { turn: 9 },
100+
state: { players: ["ada"], turnNumber: 9 },
101101
expectedVersion: 1,
102102
turnDeadline: 3_000,
103103
}),
@@ -107,6 +107,18 @@ describe("the native fix with a version column", () => {
107107
})
108108
})
109109

110+
describe("a stored room with an unexpected shape", () => {
111+
it("fails to load instead of returning unchecked state", () => {
112+
const database = openRoomStore({ path: join(directory, "store.sqlite3") })
113+
database
114+
.prepare("INSERT INTO rooms (room_id, state, version, turn_deadline) VALUES (?, ?, 1, NULL)")
115+
.run("table-9", JSON.stringify({ players: "ada" }))
116+
117+
expect(() => loadRoom({ database, roomId: "table-9" })).toThrow("unexpected shape")
118+
database.close()
119+
})
120+
})
121+
110122
describe("the TurnRoom actor", () => {
111123
it("applies one of two submissions for the same turn", async () => {
112124
const runtime = await startRuntime()

‎test/guide-race-conditions.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ const paymentJob: EventCaller = { userId: "payment-job", role: "system" }
2525

2626
let directory: string
2727
const runtimes: SolidObjectsRuntime[] = []
28+
const children: ChildProcess[] = []
2829

2930
beforeEach(async () => {
3031
directory = await mkdtemp(join(tmpdir(), "guide-race-"))
3132
})
3233

3334
afterEach(async () => {
35+
for (const child of children.splice(0)) child.kill()
3436
for (const runtime of runtimes.splice(0)) await runtime.close()
3537
await rm(directory, { recursive: true, force: true })
3638
})
@@ -43,7 +45,7 @@ async function startRuntime(): Promise<SolidObjectsRuntime> {
4345
}
4446

4547
function startHoldProcess(): ChildProcess {
46-
return fork(fileURLToPath(new URL("./fixtures/event-hold-process.ts", import.meta.url)), {
48+
const child = fork(fileURLToPath(new URL("./fixtures/event-hold-process.ts", import.meta.url)), {
4749
execArgv: [
4850
"--experimental-transform-types",
4951
"--disable-warning=ExperimentalWarning",
@@ -52,6 +54,8 @@ function startHoldProcess(): ChildProcess {
5254
],
5355
stdio: ["ignore", "inherit", "inherit", "ipc"],
5456
})
57+
children.push(child)
58+
return child
5559
}
5660

5761
function nextMessage(child: ChildProcess): Promise<unknown> {
@@ -266,6 +270,18 @@ describe("the EventSeats actor", () => {
266270
).toBe(12)
267271
})
268272

273+
it("rejects a capacity that is not a whole number of seats", async () => {
274+
const runtime = await startRuntime()
275+
const seats = runtime.ref(EventSeats, "concert").with({ authorizationContext: organizer })
276+
277+
await expect(seats.setCapacity({ capacity: 1.5, expectedRevision: 0 })).rejects.toMatchObject({
278+
code: "invalid_capacity",
279+
})
280+
await expect(seats.setCapacity({ capacity: -1, expectedRevision: 0 })).rejects.toMatchObject({
281+
code: "invalid_capacity",
282+
})
283+
})
284+
269285
it("lets only an organizer change the capacity", async () => {
270286
const runtime = await startRuntime()
271287

0 commit comments

Comments
 (0)