|
|
@@ -72,6 +72,18 @@ export interface ProjectionDefinition<
|
|
|
* @returns the whole current value for this unit's key.
|
|
|
*/
|
|
|
view(state: NoInfer<S>): SessionProjectionMap[K]
|
|
|
+ /**
|
|
|
+ * Change-detection token for the served view: after a changed `apply`,
|
|
|
+ * the feed compares this token across the previous and next states with
|
|
|
+ * `Object.is` and stays quiet when identical, so `view` runs only for an
|
|
|
+ * actual push. Must be a cheap pure read (a state field, not a
|
|
|
+ * computation). Omitted, the raw `view` output itself is the token —
|
|
|
+ * correct for identity-stable views, but then `view` runs per changed
|
|
|
+ * state, and views building fresh objects per call push on every change.
|
|
|
+ * @param state - a state on either side of the comparison.
|
|
|
+ * @returns the token deciding whether the served view changed.
|
|
|
+ */
|
|
|
+ viewKey?(state: NoInfer<S>): unknown
|
|
|
} : never
|
|
|
/**
|
|
|
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
|
|
|
@@ -135,7 +147,11 @@ interface ErasedDefinition {
|
|
|
stateSchema: { parse(value: unknown): unknown }
|
|
|
init(header: SessionHeader): unknown
|
|
|
apply(state: unknown, event: SessionEvent): unknown
|
|
|
- wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
|
|
|
+ wire: {
|
|
|
+ viewSchema: { parse(value: unknown): unknown }
|
|
|
+ view(state: unknown): unknown
|
|
|
+ viewKey?(state: unknown): unknown
|
|
|
+ } | undefined
|
|
|
stateVersion: number
|
|
|
}
|
|
|
|
|
|
@@ -169,10 +185,11 @@ interface Registration {
|
|
|
* service subscribes to `session/event` once; every committed event passes
|
|
|
* every registered unit's `apply` (eager drive), and a changed state
|
|
|
* reference in a client-visible unit notifies the change feed with the
|
|
|
- * schema-validated view — unless the raw view output is `Object.is`-identical
|
|
|
- * to the unit's previous projection (identity-stable projections stay quiet;
|
|
|
- * both views are computed from the states in hand each step, so no stored
|
|
|
- * comparison value exists to go stale across listener generations).
|
|
|
+ * schema-validated view — unless the unit's `viewKey` token (default: the raw
|
|
|
+ * view output) is `Object.is`-identical across the previous and next states.
|
|
|
+ * Both tokens are computed from the states in hand each step, so no stored
|
|
|
+ * comparison value exists to go stale across listener generations, and
|
|
|
+ * `view` runs only for an actual push when a `viewKey` is declared.
|
|
|
* Cells build lazily — a unit registered after events flowed, or a session
|
|
|
* older than the registry, folds `init` over the in-memory log on first
|
|
|
* touch (event or read). Registration is an effect (disposer rides the
|
|
|
@@ -244,7 +261,9 @@ export class SessionProjectionRegistry extends Service {
|
|
|
const wire = definition.wire as {
|
|
|
viewSchema: ZodType
|
|
|
view(state: S): unknown
|
|
|
+ viewKey?(state: S): unknown
|
|
|
} | undefined
|
|
|
+ const viewKey = wire?.viewKey
|
|
|
const erased: ErasedDefinition = {
|
|
|
key: definition.key,
|
|
|
stateSchema: definition.stateSchema,
|
|
|
@@ -252,7 +271,11 @@ export class SessionProjectionRegistry extends Service {
|
|
|
apply: (state, event) => definition.apply(state as S, event),
|
|
|
wire: wire === undefined
|
|
|
? undefined
|
|
|
- : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
|
|
|
+ : {
|
|
|
+ viewSchema: wire.viewSchema,
|
|
|
+ view: state => wire.view(state as S),
|
|
|
+ ...(viewKey === undefined ? {} : { viewKey: (state: unknown) => viewKey(state as S) }),
|
|
|
+ },
|
|
|
stateVersion: definition.stateVersion,
|
|
|
}
|
|
|
if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
|
|
|
@@ -636,14 +659,15 @@ export class SessionProjectionRegistry extends Service {
|
|
|
cell.state = next
|
|
|
cell.observedSeq = event.seq
|
|
|
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
|
|
|
- // Identity gate on the raw view, computed from the two states in
|
|
|
- // hand: a changed state whose projection is reference-identical to
|
|
|
- // the previous state's stays quiet, so a unit can buffer working
|
|
|
- // fields without spamming the feed. Nothing is stored, so no
|
|
|
- // comparison value exists to go stale across listener generations.
|
|
|
- const raw = registration.def.wire.view(next)
|
|
|
- if (Object.is(registration.def.wire.view(previous), raw)) continue
|
|
|
- const value = registration.def.wire.viewSchema.parse(raw)
|
|
|
+ // Identity gate on the unit's comparison token, computed from the
|
|
|
+ // two states in hand: identical tokens mean the served view did not
|
|
|
+ // change, so a unit can buffer working fields without spamming the
|
|
|
+ // feed and `view` runs only for an actual push. Nothing is stored,
|
|
|
+ // so no comparison value exists to go stale across listener
|
|
|
+ // generations.
|
|
|
+ const keyOf = registration.def.wire.viewKey ?? registration.def.wire.view
|
|
|
+ if (Object.is(keyOf(previous), keyOf(next))) continue
|
|
|
+ const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next))
|
|
|
for (const listener of this.listeners) {
|
|
|
listener(session, registration.def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
|
|
|
}
|