Skip to content

Commit aa461cc

Browse files
committed
Align typed snapshot values
1 parent a794e0d commit aa461cc

8 files changed

Lines changed: 47 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

33
## Unreleased
44

5+
- Make typed snapshots return persisted fields and getter values from one
6+
committed state image, rejecting getters that mutate state or stage work.
57
- Probe configured authorization policies with a neutral context in the doctor
68
and warn when sensitive access is open or a policy cannot evaluate safely.
79
- Align effect and commit-action context with their durable source message and

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,9 @@ IDs and observable values are not authorization.
809809
- Failed turns roll state and staged intents back and block later work until
810810
retry or dead-letter completion.
811811
- Effects can execute more than once.
812-
- Results and snapshots are deeply frozen copies.
812+
- Results and snapshots are deeply frozen copies. A snapshot contains every
813+
persisted field and getter from one committed state image; snapshot getters
814+
must not mutate state or stage durable work.
813815
814816
Override protected `onActivate()` and `onDeactivate()` methods when an actor
815817
needs a process-local resource during that window. Hooks may be asynchronous,

‎docs/correctness.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
permits only `SELECT` through row-returning methods; commit actions remain the
3434
fenced write path. The guarantee applies only to clients passed through the
3535
facade.
36+
- Snapshots hydrate one committed state image and evaluate every inferred getter
37+
against it. Getter mutation or staged durable work rejects the whole snapshot;
38+
successful snapshots and their nested JSON values are frozen copies.
3639
- Personalized payloads hydrate committed state separately for every payload
3740
name and subscriber. Each projection is read-only, size bounded, and fenced
3841
independently by actor incarnation and revision. One denied, mutating, or

‎docs/parity.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
3434
| Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
3535
| Ambient transaction rejection | Native | Committed calls and message waits fail before blocking when the current async context already owns a transaction on the Solid Objects adapter. |
3636
| Direct application-write isolation during actor code | Partial | `guardApplicationDatabase()` fails closed for operations, projections, and migrations, while registered commit actions remain writable. Unwrapped ORM pools and third-party clients cannot be intercepted. |
37-
| Committed snapshots | Native | `snapshot()` returns authorized committed state; realtime replay reads the explicit observable projection with instance ID and revision without creating mailbox history. |
37+
| Committed snapshots | Native | `snapshot()` returns authorized persisted fields and inferred getters from one read-only committed state image; realtime replay reads explicit observables without mailbox history. |
3838
| Actor destruction and incarnation fencing | Native | Authorized cascading deletion creates a fresh instance ID on recreation; an authorized waiter receives `ActorDestroyed` when that incarnation disappears. |
3939
| Result recovery and sync timeout diagnostics | Native | Status, result, and wait reauthorize the stored operation; terminal failure raises structured `MessageFailed`; whole-call adapter deadlines distinguish enqueue, wait, database, activation, and mailbox blockers. |
4040
| Result lookup by request ID | Planned | This is also an open Ruby roadmap item and will be implemented in both runtimes when its authorization shape is settled. |

‎src/reference.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export type ActorQueryNames<ActorType extends Actor> = Exclude<
3030
>
3131

3232
export type ActorSnapshot<ActorType extends Actor> = {
33-
readonly [Key in ActorQueryNames<ActorType>]: DeepReadonly<ActorType[Key]>
33+
readonly [Key in ActorQueryNames<ActorType>]: DeepReadonly<Awaited<ActorType[Key]>>
3434
}
3535

3636
type MethodResult<Method> = Method extends (...argumentsValue: any[]) => infer Result

‎src/runtime.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,30 @@ export class SolidObjectsRuntime {
683683
storedState: jsonObject(JSON.parse(instance.state)),
684684
})
685685
: initialStateFor(registered.definition)
686-
return readonlyCopy(state) as ActorSnapshot<ActorType>
686+
const actor = hydrateActor({
687+
definition: registered.definition,
688+
actorId: reference.actorId,
689+
state,
690+
})
691+
const stateBefore = stableJson(actorState(actor, registered.definition.stateKeys))
692+
const intentCount = actor.intentCount()
693+
const snapshot: Record<string, JsonValue> = { ...state }
694+
await withActorProjection({ actor, runtime: this }, async () => {
695+
for (const query of registered.definition.queries) {
696+
if (registered.definition.stateKeys.includes(query)) continue
697+
const value = await (actor as unknown as Record<string, unknown>)[query]
698+
snapshot[query] = normalizeJson(value === undefined ? null : value, {
699+
maxBytes: this.settings.maxResultBytes,
700+
})
701+
}
702+
})
703+
if (
704+
stableJson(actorState(actor, registered.definition.stateKeys)) !== stateBefore ||
705+
actor.intentCount() !== intentCount
706+
) {
707+
throw new QueryMutatedState("snapshot getters must not mutate actor state or stage work")
708+
}
709+
return readonlyCopy(snapshot) as ActorSnapshot<ActorType>
687710
}
688711

689712
async subscriptionSnapshot(options: {

‎test/correctness.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ActorDestroyed,
66
IdempotencyConflict,
77
MessageFailed,
8+
QueryMutatedState,
89
Rejected,
910
SyncInsideTransaction,
1011
Unauthorized,
@@ -324,6 +325,14 @@ describe("durable invocation correctness", () => {
324325
expect(effect).toBeUndefined()
325326
})
326327

328+
it("rejects snapshot getters that mutate state", async () => {
329+
runtime = configuredRuntime()
330+
await runtime.install()
331+
332+
await expect(MutatingQuery.ref("snapshot").snapshot()).rejects.toBeInstanceOf(QueryMutatedState)
333+
expect(await MutatingQuery.ref("snapshot").count).toBe(0)
334+
})
335+
327336
it("rejects observables that mutate state or stage durable work", async () => {
328337
runtime = configuredRuntime({ maxAttempts: 1 })
329338
await runtime.install()

‎test/runtime.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,13 @@ describe("typed actor references", () => {
142142
expect(await counter.increment({ amount: 3 })).toBe(3)
143143
expect(await counter.count).toBe(3)
144144
expect(await counter.doubled).toBe(6)
145+
const snapshot = await counter.snapshot()
146+
expect(snapshot).toEqual({ count: 3, lastAuthorizationContext: null, doubled: 6 })
147+
expect(Object.isFrozen(snapshot)).toBe(true)
145148

146149
expectTypeOf(counter.increment).returns.toEqualTypeOf<Promise<number>>()
147150
expectTypeOf(counter).toHaveProperty("count").toEqualTypeOf<Promise<number>>()
151+
expectTypeOf(snapshot.doubled).toEqualTypeOf<number>()
148152
expectTypeOf(counter).not.toHaveProperty("async")
149153
expectTypeOf(counter).not.toHaveProperty("sync")
150154
})

0 commit comments

Comments
 (0)