Skip to content

Commit c77d15a

Browse files
committed
fix: Detect replaced work in projections
Compare complete staged work around projections so draining one intent and staging another cannot bypass the read-only contract. Cover effect and commit-action replacement with terminal rollback regressions.
1 parent d9b6052 commit c77d15a

6 files changed

Lines changed: 75 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Detect observable replacement of staged work even when the intent count is
6+
unchanged. Compare complete intent snapshots around projection evaluation.
7+
58
- Preserve reserved JSON keys such as `__proto__` as own data properties without
69
changing object prototypes. Include them in size limits, state, arguments, and
710
retained results; share compatibility fixtures with Ruby.

‎docs/parity.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ is needed for the JavaScript stale-result race fix.
5151
| Capability | Status | TypeScript shape or remaining work |
5252
| ------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
5353
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
54-
| Query and observable purity | Native | Both runtimes reject state mutation and staged effects, recovery checks, commit actions, reminders, or outbound messages in queries and observable projections. Violations fail terminally with `QueryMutatedState`; snapshot projection reads enforce the same rule. |
54+
| Query and observable purity | Native | Both runtimes reject state mutation and staged effects, recovery checks, commit actions, reminders, or outbound messages in queries and observable projections. Projection guards compare the complete staged work, including same-count replacements. Violations fail terminally with `QueryMutatedState`; snapshot projection reads enforce the same rule. |
5555
| JSON property preservation | Native | Reserved property names such as `__proto__`, `constructor`, and `prototype` remain ordinary data, including through size checks and immutable copies. Both suites consume `compatibility/json-values.json`. |
5656
| Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. |
5757
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. |

‎src/actor.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,10 @@ export abstract class Actor {
536536
intentCount(): number {
537537
return Object.values(this.#intents).reduce((count, intents) => count + intents.length, 0)
538538
}
539+
540+
intentSnapshot(): string {
541+
return JSON.stringify(this.#intents)
542+
}
539543
}
540544

541545
function isObservableBroadcast(value: object): value is ObservableBroadcast<JsonValue> {

‎src/runtime.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ export class SolidObjectsRuntime {
836836
: (await this.repository.remindersForInstance(instance.id)).map(scheduledReminderOf),
837837
})
838838
const stateBefore = stableJson(actorState(actor, registered.definition.stateKeys))
839-
const intentCount = actor.intentCount()
839+
const intentsBefore = actor.intentSnapshot()
840840
const snapshot: Record<string, JsonValue> = { ...state }
841841
await withActorProjection({ actor, runtime: this }, async () => {
842842
for (const query of registered.definition.queries) {
@@ -849,7 +849,7 @@ export class SolidObjectsRuntime {
849849
})
850850
if (
851851
stableJson(actorState(actor, registered.definition.stateKeys)) !== stateBefore ||
852-
actor.intentCount() !== intentCount
852+
actor.intentSnapshot() !== intentsBefore
853853
) {
854854
throw new QueryMutatedState("snapshot getters must not mutate actor state or stage work")
855855
}
@@ -2227,7 +2227,7 @@ export class SolidObjectsRuntime {
22272227
state: deepCopy(options.snapshot.state),
22282228
})
22292229
const stateBefore = stableJson(actorState(actor, options.registered.definition.stateKeys))
2230-
const intentCount = actor.intentCount()
2230+
const intentsBefore = actor.intentSnapshot()
22312231
const handler = options.registered.definition.payloads[options.name]
22322232
if (!handler) {
22332233
throw new UnknownPayloadBroadcast(`unknown payload broadcast ${options.name}`)
@@ -2237,7 +2237,7 @@ export class SolidObjectsRuntime {
22372237
)
22382238
if (
22392239
stableJson(actorState(actor, options.registered.definition.stateKeys)) !== stateBefore ||
2240-
actor.intentCount() !== intentCount
2240+
actor.intentSnapshot() !== intentsBefore
22412241
) {
22422242
throw new QueryMutatedState("payload broadcasts must not mutate actor state or stage work")
22432243
}

‎src/turn.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ export function readActorObservables(options: {
1414
}): ObservableProjection {
1515
const { actor, definition, runtime } = options
1616
const before = options.stateJson ?? stableJson(actorState(actor, definition.stateKeys))
17-
const intentCount = actor.intentCount()
17+
const intentsBefore = actor.intentSnapshot()
1818
const projection = withActorProjection({ actor, runtime }, () => actor.observableValues())
1919
if (
2020
stableJson(actorState(actor, definition.stateKeys)) !== before ||
21-
actor.intentCount() !== intentCount
21+
actor.intentSnapshot() !== intentsBefore
2222
) {
2323
throw new QueryMutatedState("observables must not mutate actor state or stage durable work")
2424
}

‎test/read-only-projections.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect, it } from "vitest"
2+
import { Actor } from "../src/actor.js"
3+
import { sqlite } from "../src/database/sqlite.js"
4+
import { createRuntime } from "../src/runtime.js"
5+
6+
class ReplacingProjection extends Actor {
7+
static override readonly actorType = "replacing-projection"
8+
action = ""
9+
10+
stage({ action }: { action: string }): void {
11+
this.action = action
12+
if (action === "effect") this.emit("original")
13+
if (action === "commit_action") this.commitAction("original")
14+
}
15+
16+
override observables(): Record<string, unknown> {
17+
if (!this.hasIntents()) return { action: this.action }
18+
19+
this.discardIntents()
20+
if (this.action === "effect") this.emit("replacement")
21+
if (this.action === "commit_action") this.commitAction("replacement")
22+
return { action: this.action }
23+
}
24+
}
25+
26+
it.each(["effect", "commit_action"])(
27+
"rejects observable replacement of staged %s without retrying or committing",
28+
async (action) => {
29+
const committed: string[] = []
30+
const runtime = createRuntime({
31+
database: sqlite({ path: ":memory:" }),
32+
authorizeMessage: () => true,
33+
authorizeQuery: () => true,
34+
maxAttempts: 3,
35+
retryDelayMilliseconds: () => 0,
36+
})
37+
try {
38+
runtime.register(ReplacingProjection)
39+
runtime.registerCommitAction("original", () => {
40+
committed.push("original")
41+
})
42+
runtime.registerCommitAction("replacement", () => {
43+
committed.push("replacement")
44+
})
45+
await runtime.install()
46+
const reference = runtime.ref(ReplacingProjection, "one")
47+
const message = await reference.send.stage({ action })
48+
await runtime.worker().runUntilIdle()
49+
50+
expect(await message.outcome()).toMatchObject({
51+
status: "dead",
52+
error: { name: "QueryMutatedState" },
53+
attempts: 1,
54+
})
55+
expect((await reference.snapshot()).action).toBe("")
56+
expect(committed).toEqual([])
57+
} finally {
58+
await runtime.close()
59+
}
60+
},
61+
)

0 commit comments

Comments
 (0)