From ec2a6f152ca1dfc0288d7a365dcf4ab4ffebc76a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 20 Apr 2026 11:57:27 +0100 Subject: [PATCH] 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. --- apps/draupnir/src/commands/WatchPreview.tsx | 5 +- .../src/Projection/Projection.ts | 9 ++- .../src/Projection/ProjectionNode.ts | 64 +++++++++++++++++-- .../MemberBanIntentProjectionNode.ts | 39 +++++++---- .../PolicyListBridgeProjection.ts | 1 + .../ServerBanIntentProjectionNode.ts | 39 +++++++---- 6 files changed, 122 insertions(+), 35 deletions(-) diff --git a/apps/draupnir/src/commands/WatchPreview.tsx b/apps/draupnir/src/commands/WatchPreview.tsx index 6f3ec1a2..15a93f2c 100644 --- a/apps/draupnir/src/commands/WatchPreview.tsx +++ b/apps/draupnir/src/commands/WatchPreview.tsx @@ -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 = { diff --git a/packages/matrix-protection-suite/src/Projection/Projection.ts b/packages/matrix-protection-suite/src/Projection/Projection.ts index 142bf340..4fcfd9d5 100644 --- a/packages/matrix-protection-suite/src/Projection/Projection.ts +++ b/packages/matrix-protection-suite/src/Projection/Projection.ts @@ -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 { diff --git a/packages/matrix-protection-suite/src/Projection/ProjectionNode.ts b/packages/matrix-protection-suite/src/Projection/ProjectionNode.ts index dfae2ffe..6188ed84 100644 --- a/packages/matrix-protection-suite/src/Projection/ProjectionNode.ts +++ b/packages/matrix-protection-suite/src/Projection/ProjectionNode.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Gnuxie +// SPDX-FileCopyrightText: 2025 - 2026 Gnuxie // // SPDX-License-Identifier: Apache-2.0 // @@ -25,25 +25,75 @@ export type ExtractInputProjectionNodes< TProjectionNode extends ProjectionNode, > = TProjectionNode extends ProjectionNode ? 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, > = { readonly ulid: ULID; // Whether the projection has no state at all. isEmpty(): boolean; - reduceInput(input: ExtractInputDeltaShapes): 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 + ): ProjectionNodeDelta; + /** + * 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; + 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; // only needed for persistent storage - reduceRebuild?(inputs: TInputs): TDeltaShape; + reduceRebuild?( + inputs: TInputs + ): ProjectionNodeDelta; } & TAccessMixin; export type AnyProjectionNode = ProjectionNode; diff --git a/packages/matrix-protection-suite/src/Protection/StandardProtections/MemberBanSynchronisation/MemberBanIntentProjectionNode.ts b/packages/matrix-protection-suite/src/Protection/StandardProtections/MemberBanSynchronisation/MemberBanIntentProjectionNode.ts index ac503fca..0218daf5 100644 --- a/packages/matrix-protection-suite/src/Protection/StandardProtections/MemberBanSynchronisation/MemberBanIntentProjectionNode.ts +++ b/packages/matrix-protection-suite/src/Protection/StandardProtections/MemberBanSynchronisation/MemberBanIntentProjectionNode.ts @@ -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 { + 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 { 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, }; } diff --git a/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/PolicyListBridgeProjection.ts b/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/PolicyListBridgeProjection.ts index a467285c..f4e743f6 100644 --- a/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/PolicyListBridgeProjection.ts +++ b/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/PolicyListBridgeProjection.ts @@ -14,5 +14,6 @@ import { ProjectionNode } from "../../../Projection/ProjectionNode"; export type PolicyListBridgeProjectionNode = ProjectionNode< [], PolicyRuleChange[], + undefined, PolicyListRevision >; diff --git a/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/ServerBanIntentProjectionNode.ts b/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/ServerBanIntentProjectionNode.ts index 31351d01..a0098a40 100644 --- a/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/ServerBanIntentProjectionNode.ts +++ b/packages/matrix-protection-suite/src/Protection/StandardProtections/ServerBanSynchronisation/ServerBanIntentProjectionNode.ts @@ -8,7 +8,10 @@ // 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 { + return { + downstreamDelta: ServerBanIntentProjectionHelper.reduceIntentDelta( + ServerBanIntentProjectionHelper.reducePolicyDelta(input), + this.policies + ), + nodeStateDelta: undefined, + }; } + reduceInitialInputs([policyListRevision]: [ PolicyListBridgeProjectionNode, - ]): ServerBanIntentProjectionDelta { + ]): ProjectionNodeDelta { 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,