You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: code/core/src/shared/open-service/README.md
+48-11Lines changed: 48 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,8 +6,9 @@ Its goals are:
6
6
7
7
- define stateful services in one declarative object
8
8
- expose synchronous queries and async commands with strong TypeScript inference
9
-
- validate all query and command input/output through Standard Schema
10
-
- support reactive query subscriptions through `alien-signals`
9
+
- validate all query and command input/output through Standard Schema (schemas may transform/coerce)
10
+
- support fine-grained reactive query subscriptions through deep signals (`deepsignal` +
11
+
`@preact/signals-core`)
11
12
- support server-side static state snapshots driven by query `load` hooks
12
13
13
14
The main audience for this README is agents and maintainers who need to understand how the pieces
@@ -94,6 +95,15 @@ Query handlers do **not** receive `commands` or `setState`. Mutations belong in
94
95
95
96
`load` mutations must go through commands. Cross-service `getService(...).queries.*` calls inside a load body are not auto-tracked for the drain; use `await ctx.getService(id).queries.foo.loaded(input)` when you need a cross-service dependency awaited before your own load completes.
96
97
98
+
**`load` is a reactive, idempotent warming step.** For an active subscription, `load` re-fires whenever the external signals it reads synchronously change — same-service fields and cross-service reads via `getService(...).queries.*` alike — turning a query into a reactive async resource (like a TanStack Query / SolidJS `createResource` / Vue async `watchEffect`). This means:
99
+
100
+
-**`load` must be idempotent.** Re-running it with the same dependencies must produce the same state. Any genuinely one-shot side effect belongs in a command invoked conditionally, never in `load` itself.
101
+
-**Read dependencies synchronously, up front.** Only reads in the load's synchronous prefix (before the first `await`) are tracked. Read the values you depend on first, then do async work — the same idiom every signal-based resource uses.
102
+
-**Loads that read no external signal fire exactly once** (the common case: `await ctx.self.commands.x(input)`), so existing loads are unaffected.
103
+
- Direct `query()` / `.loaded()` calls are **not** reactive — they keep one-shot-per-call semantics. Reactivity is scoped to subscriptions and is torn down when the last subscriber unsubscribes.
104
+
105
+
The runtime guards re-firing: a superseded run (its dependencies changed again before it finished) cannot overwrite a newer run's state, and changes batched together produce a single re-load.
106
+
97
107
**Keep `load` bodies as small as possible.** Almost always, `load` should be a one-liner that calls a command — the real work (input resolution, side effects, validation, state mutation) belongs in the command. This pays off for three reasons:
98
108
99
109
-**Reusability.** Anyone can call the command directly (other services, tests, integrations) without going through the query's load path. Logic stuck inside a load is unreachable from outside the drain.
@@ -158,7 +168,12 @@ Both must be Standard Schema compatible.
158
168
The runtime validates:
159
169
160
170
- caller input before a handler runs
161
-
- handler output before the result is returned or emitted
171
+
- handler output when a value is produced for a consumer — a direct `query()` call, `query.loaded()`,
172
+
the static build, and a subscription emission
173
+
174
+
Output validation reads the whole value, so it is kept out of the part of a subscription that
175
+
determines reactive dependencies: for a `selector` subscriber it runs without tracking, so it cannot
176
+
expand the deep-signal dependency footprint. (See "Subscription Flow".)
162
177
163
178
Queries validate **synchronously**. Their input and output schemas must produce sync results. If a Standard Schema returns a Promise during a query validation, the runtime throws `OpenServiceAsyncSchemaError` immediately.
164
179
@@ -250,15 +265,37 @@ When an **async** `load` body runs, it instead gets a *wrapped* `ctx.self.querie
250
265
251
266
Cross-service `ctx.getService(id).queries.*` calls inside a load body are **not** wrapped; authors must use `.loaded()` explicitly when they need a cross-service dep awaited from inside a load. From a sync handler, cross-service queries are tracked because they consult the module-scoped session like any other call.
252
267
268
+
## State and reactivity
269
+
270
+
State is a **deep reactive proxy** (`deepSignal` from `deepsignal`, backed by `@preact/signals-core`)
271
+
created in [service-runtime.ts](./service-runtime.ts). There is no top-level state atom and no Immer:
272
+
273
+
- Reading a field through `ctx.self.state` tracks a fine-grained signal for exactly that field
274
+
(including not-yet-present record keys, which fire when the key is later added).
275
+
-`setState((state) => …)` mutates the proxy **in place** inside a batch, so one command notifies
276
+
subscribers once, and only the fields it actually changed are invalidated.
277
+
- The proxy is internal and does not escape:
278
+
- Query/`.loaded()` results are the schema-validated value. For object and array schemas that
279
+
rebuild a plain value, this also detaches the result from the proxy.
280
+
- Subscription emissions are detached to plain values (validated for whole-value subscribers, or
281
+
JSON-stripped for `selector` slices).
282
+
- The whole-state snapshot for the static build uses `structuredClone` of the plain backing
283
+
object. (`structuredClone` cannot clone a proxy, so proxy-slice stripping uses a JSON round-trip;
284
+
state must be JSON-serializable, the same constraint the static-build pipeline relies on.)
285
+
253
286
## Subscription Flow
254
287
255
-
Subscriptions are implemented with `alien-signals`in [service-runtime.ts](./service-runtime.ts):
288
+
Subscriptions are implemented in [service-runtime.ts](./service-runtime.ts):
256
289
257
-
1.`subscribe(input, callback)` defers all work to a microtask.
258
-
2. The microtask validates the input synchronously and fires the dependency's `load` in the background.
259
-
3. A `computed()` value wraps the synchronous handler. An `effect()` runs the handler immediately (delivering the current value to the callback) and re-runs whenever the handler's tracked state dependencies change.
260
-
4. Subscribers receive the current state right away, then a follow-up emission once the load settles and state changes. UI consumers that want to suppress the pre-load emission should branch on the value (e.g. show a spinner for `null`).
261
-
5. Each emitted value is output-validated before the subscriber callback runs.
290
+
1.`subscribe(input, callback)` (or `subscribe(input, selector, callback)`) defers all work to a microtask.
291
+
2. The microtask validates the input synchronously. If the query has a `load`, it is run inside its own `effect()` so the external signals it reads synchronously are tracked: when they change, the effect re-runs and the load re-fires (see "Load"). Writes from a superseded run are dropped (each run carries an epoch; `setState` is gated on it), so a slow stale load can't clobber a newer result. The effect is torn down with the subscription.
292
+
3. A `computed()` runs the synchronous handler against the deep-signal proxy, so its dependency footprint is exactly what it reads. The output is always validated, but where validation runs depends on the subscription:
293
+
-**No selector:** the value is validated here and emitted. Reading the whole value to validate it is the correct footprint for a whole-value subscriber, and it keeps the emitted value identical to a direct `query()` pull.
294
+
-**With a selector:** validation runs untracked (so it does not register dependencies) and only `selector(value)` is read (then detached to a plain snapshot), so a sibling field the selector ignores never re-runs the handler.
295
+
4. An `effect()` runs the computed immediately (delivering the current value) and re-runs only when the computed's tracked fields change. A write to an unrelated key or field never re-runs the handler.
296
+
5. Subscribers receive the current state right away, then a follow-up emission once the load settles and state changes. UI consumers that want to suppress the pre-load emission should branch on the value (e.g. show a spinner for `null`).
297
+
6. Emissions are deduped by value: the effect compares the new value with the last emitted one via `es-toolkit``isEqual` and skips the callback when they are equal. So a load that rewrites a deeply-equal value does not re-fire subscribers.
298
+
7. The optional `selector` is the `universal-store` pattern: the callback receives the selected slice and fires only when that slice changes by value — and, because the selector drives the computed's reads, an unselected field change does not even re-run the handler.
262
299
263
300
Tests should use `vi.waitFor(...)` when asserting the first emission or follow-up emissions.
0 commit comments