Allow projections to pass node state in the delta.

https://github.com/the-draupnir-project/planning/issues/123.

The existing projections have been changed to the new API, but they
probably could be optimised now that we have this feature.
This commit is contained in:
gnuxie
2026-04-20 13:03:29 +01:00
parent ecfcc1c2c3
commit ec2a6f152c
6 changed files with 122 additions and 35 deletions
+3 -2
View File
@@ -38,14 +38,15 @@ function watchDeltaForMemberBanIntents(
);
return memberBanIntentProjectionNode.reduceInput(
setMembershipPoliciesRevisionDelta
);
).downstreamDelta;
}
function watchDeltaForServerBanIntents(
watchedPoliciesDelta: PolicyRuleChange[],
serverBanIntentProjectionNode: ServerBanIntentProjectionNode
) {
return serverBanIntentProjectionNode.reduceInput(watchedPoliciesDelta);
return serverBanIntentProjectionNode.reduceInput(watchedPoliciesDelta)
.downstreamDelta;
}
export type WatchPolicyRoomPreview = {
@@ -70,9 +70,14 @@ export class ProjectionOutputHelper<
const delta = previousNode.reduceInput(input);
this.currentNode = previousNode.reduceDelta(delta) as TProjectionNode;
for (const output of this.outputs) {
output.applyInput(delta);
output.applyInput(delta.downstreamDelta);
}
this.emitter.emit("projection", this.currentNode, delta, previousNode);
this.emitter.emit(
"projection",
this.currentNode,
delta.downstreamDelta,
previousNode
);
}
addOutput(projection: Projection): this {
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
// SPDX-FileCopyrightText: 2025 - 2026 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: Apache-2.0
//
@@ -25,25 +25,75 @@ export type ExtractInputProjectionNodes<
TProjectionNode extends ProjectionNode,
> = TProjectionNode extends ProjectionNode<infer TInputs> ? TInputs : never;
export type ProjectionNodeDelta<
TDownstreamDeltaShape = unknown,
TNodeStateDeltaShape = unknown,
> = {
/**
* The externally visible delta produced by this node. This is the only
* delta that should be propagated to downstream projection inputs and
* projection listeners.
*/
readonly downstreamDelta: TDownstreamDeltaShape;
/**
* The authoritative state transition for this node. This delta must contain
* all information needed by `reduceDelta` to update the node's internal
* state, even when the externally visible downstream delta is empty.
*/
readonly nodeStateDelta: TNodeStateDeltaShape;
};
export type ProjectionNode<
TInputs extends ProjectionNode[] | unknown[] = unknown[],
TDeltaShape = unknown,
TDownstreamDeltaShape = unknown,
TNodeStateDeltaShape = unknown,
TAccessMixin = Record<never, never>,
> = {
readonly ulid: ULID;
// Whether the projection has no state at all.
isEmpty(): boolean;
reduceInput(input: ExtractInputDeltaShapes<TInputs>): TDeltaShape;
/**
* Reduces an input delta into a full projection-node delta.
*
* The downstream delta is the published effect for downstream projections.
* The node-state delta is the complete internal state transition for this
* node. These must be derived from the same input and previous node state so
* that persisted nodes can be rebuilt by replaying or recomputing node-state
* deltas while still producing corrective downstream deltas.
*/
reduceInput(
input: ExtractInputDeltaShapes<TInputs>
): ProjectionNodeDelta<TDownstreamDeltaShape, TNodeStateDeltaShape>;
/**
* Applies a full projection-node delta to produce the next node.
*
* Implementations must update internal state from `nodeStateDelta`, not from
* `downstreamDelta`. The downstream delta can omit internal transitions that
* do not cross a downstream-visible boundary, so using it as the source of
* truth can make the node inconsistent with its persisted/rebuilt state.
*/
reduceDelta(
input: TDeltaShape
): ProjectionNode<TInputs, TDeltaShape, TAccessMixin>;
projectionNodeDelta: ProjectionNodeDelta<
TDownstreamDeltaShape,
TNodeStateDeltaShape
>
): ProjectionNode<
TInputs,
TDownstreamDeltaShape,
TNodeStateDeltaShape,
TAccessMixin
>;
/**
* Produces the initial delta, can only be used when the revision is empty.
* Otherwise you must use reduceRebuild.
*/
reduceInitialInputs(input: TInputs): TDeltaShape;
reduceInitialInputs(
input: TInputs
): ProjectionNodeDelta<TDownstreamDeltaShape, TNodeStateDeltaShape>;
// only needed for persistent storage
reduceRebuild?(inputs: TInputs): TDeltaShape;
reduceRebuild?(
inputs: TInputs
): ProjectionNodeDelta<TDownstreamDeltaShape, TNodeStateDeltaShape>;
} & TAccessMixin;
export type AnyProjectionNode = ProjectionNode<never>;
@@ -11,6 +11,7 @@ import { ULID, ULIDFactory } from "ulidx";
import {
ExtractInputDeltaShapes,
ProjectionNode,
ProjectionNodeDelta,
} from "../../../Projection/ProjectionNode";
import {
MemberPolicyMatch,
@@ -33,7 +34,8 @@ import { ListMultiMap } from "../../../Projection/ListMultiMap";
*/
export type MemberBanInputProjectionNode = ProjectionNode<
never[],
MembershipPolicyRevisionDelta
MembershipPolicyRevisionDelta,
undefined
> &
MembershipPolicyRevision;
@@ -57,6 +59,7 @@ function isPolicyRelevant(policy: LiteralPolicyRule | GlobPolicyRule): boolean {
export type MemberBanIntentProjectionNode = ProjectionNode<
[MemberBanInputProjectionNode],
MemberBanIntentProjectionDelta,
undefined,
{
allMembersWithRules(): MemberPolicyMatches[];
allRulesMatchingMember(
@@ -135,16 +138,23 @@ export class StandardMemberBanIntentProjectionNode implements MemberBanIntentPro
reduceInput(
input: ExtractInputDeltaShapes<[MemberBanInputProjectionNode]>
): MemberBanIntentProjectionDelta {
return MemberBanIntentProjectionNodeHelper.reduceIntentDelta(
MemberBanIntentProjectionNodeHelper.reduceMembershipPolicyDelta(input),
this.intents
);
): ProjectionNodeDelta<MemberBanIntentProjectionDelta, undefined> {
return {
downstreamDelta: MemberBanIntentProjectionNodeHelper.reduceIntentDelta(
MemberBanIntentProjectionNodeHelper.reduceMembershipPolicyDelta(input),
this.intents
),
nodeStateDelta: undefined,
};
}
reduceDelta(
input: MemberBanIntentProjectionDelta
): StandardMemberBanIntentProjectionNode {
projectionNodeDelta: ProjectionNodeDelta<
MemberBanIntentProjectionDelta,
undefined
>
): MemberBanIntentProjectionNode {
const input = projectionNodeDelta.downstreamDelta;
let nextIntents = this.intents;
nextIntents = ListMultiMap.addValues(
nextIntents,
@@ -164,7 +174,7 @@ export class StandardMemberBanIntentProjectionNode implements MemberBanIntentPro
reduceInitialInputs([membershipPolicyRevision]: [
MemberBanInputProjectionNode,
]): MemberBanIntentProjectionDelta {
]): ProjectionNodeDelta<MemberBanIntentProjectionDelta, undefined> {
if (!this.isEmpty()) {
throw new TypeError(
"This can only be called on an empty projection node"
@@ -177,10 +187,13 @@ export class StandardMemberBanIntentProjectionNode implements MemberBanIntentPro
)
.flat();
return {
add: matches,
ban: matches.map((match) => match.userID),
remove: [],
recall: [],
downstreamDelta: {
add: matches,
ban: matches.map((match) => match.userID),
remove: [],
recall: [],
},
nodeStateDelta: undefined,
};
}
@@ -14,5 +14,6 @@ import { ProjectionNode } from "../../../Projection/ProjectionNode";
export type PolicyListBridgeProjectionNode = ProjectionNode<
[],
PolicyRuleChange[],
undefined,
PolicyListRevision
>;
@@ -8,7 +8,10 @@
// </text>
import { StringServerName } from "@the-draupnir-project/matrix-basic-types";
import { ProjectionNode } from "../../../Projection/ProjectionNode";
import {
ProjectionNode,
ProjectionNodeDelta,
} from "../../../Projection/ProjectionNode";
import { PolicyListBridgeProjectionNode } from "./PolicyListBridgeProjection";
import {
PolicyRuleChange,
@@ -38,6 +41,7 @@ export type ServerBanIntentProjectionDelta = {
export type ServerBanIntentProjectionNode = ProjectionNode<
[PolicyListBridgeProjectionNode],
ServerBanIntentProjectionDelta,
undefined,
{
deny: StringServerName[];
}
@@ -122,15 +126,21 @@ export class StandardServerBanIntentProjectionNode implements ServerBanIntentPro
);
}
reduceInput(input: PolicyRuleChange[]): ServerBanIntentProjectionDelta {
return ServerBanIntentProjectionHelper.reduceIntentDelta(
ServerBanIntentProjectionHelper.reducePolicyDelta(input),
this.policies
);
reduceInput(
input: PolicyRuleChange[]
): ProjectionNodeDelta<ServerBanIntentProjectionDelta, undefined> {
return {
downstreamDelta: ServerBanIntentProjectionHelper.reduceIntentDelta(
ServerBanIntentProjectionHelper.reducePolicyDelta(input),
this.policies
),
nodeStateDelta: undefined,
};
}
reduceInitialInputs([policyListRevision]: [
PolicyListBridgeProjectionNode,
]): ServerBanIntentProjectionDelta {
]): ProjectionNodeDelta<ServerBanIntentProjectionDelta, undefined> {
if (!this.isEmpty()) {
throw new TypeError("Cannot reduce initial inputs when inialised");
}
@@ -145,21 +155,28 @@ export class StandardServerBanIntentProjectionNode implements ServerBanIntentPro
),
].filter((rule) => rule.matchType !== PolicyRuleMatchType.HashedLiteral);
const names = new Set(serverPolicies.map((policy) => policy.entity));
return {
const downstreamDelta = {
add: serverPolicies,
deny: [...names] as StringServerName[],
remove: [],
recall: [],
};
return {
downstreamDelta,
nodeStateDelta: undefined,
};
}
isEmpty(): boolean {
return this.policies.size === 0;
}
reduceDelta(
input: ServerBanIntentProjectionDelta
): ServerBanIntentProjectionNode {
reduceDelta({
downstreamDelta: input,
}: ProjectionNodeDelta<
ServerBanIntentProjectionDelta,
undefined
>): ServerBanIntentProjectionNode {
let nextPolicies = this.policies;
nextPolicies = ListMultiMap.addValues(
nextPolicies,