Files
Shade/packages/shade-inbox-server/src/bridge-delivery-log.ts
Sterister a98ea8a1bd
Some checks failed
Test / test (push) Has been cancelled
release(v4.8.4): server-side cross-channel dedup via BridgeDeliveryLog
V4.8.3 shipped client-side cross-channel dedup hook
(`Inbox.acceptBridgeFrame`), but recipients that didn't migrate to
the new wiring still observed the same envelope twice — once via
WS bridge push, again ~30 s later via inbox-poll. Prism re-verified
the FR after 4.8.3 and asked for a relay-side enforcement so app
code doesn't have to ack-via-DELETE on every bridge frame.

V4.8.4 adds an in-memory `BridgeDeliveryLog` (default 60 s grace,
8192-per-address cap) that records every successful WS / SSE /
long-poll push of `(address, msgId)`. The `/v1/inbox/:addr/fetch`
route filters out blobs in the log's grace window so a recipient
running both a bridge and the 30 s poll cadence sees exactly one
delivery. Cursor advances over the full fetched window so a poll
that straddles a suppressed blob doesn't stall.

The standalone server auto-wires the log between
`createBridgeRoutes` and `createInboxRoutes`. Custom mounts thread
the same instance through `bridgeDeliveryLog` on both factories.

Tests cover WS-then-poll, SSE-then-poll, and a negative control
(non-bridge-pushed blob still comes through inbox-fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:31:42 +02:00

141 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BridgeDeliveryLog — V4.8.4 cross-channel dedup.
*
* Records per-`(address, msgId)` "delivered via bridge push" timestamps so
* the inbox-fetch route can filter out blobs the bridge has already pushed
* to the recipient. The relay's contract becomes:
*
* one `Inbox.send` ⇒ one observable delivery on the recipient
*
* even when the recipient runs both a bridge subscription (WS / SSE) AND
* the regular inbox-poll. Without it, bridge-push and inbox-poll are
* independent paths against the same store and the recipient gets the
* same envelope twice — bridge-first, then ~30 s later via the next poll
* — tripping on already-consumed prekeys (`one-time prekey not found`)
* or surfacing as duplicate `shade.receive` work.
*
* The log is in-memory per process and intentionally bounded: each entry
* lives for `graceMs` (default 60 s, well past a typical `pollIntervalMs`
* of 30 s). After grace, the entry is forgotten and inbox-poll falls back
* to delivering the blob — that's the legitimate "bridge dropped the
* frame, poll picked up" recovery path. If the recipient explicitly
* acks the blob (HTTP `DELETE /v1/inbox/:addr/:msgId`), the blob is gone
* from storage and the log entry is moot.
*
* Multi-bridge per address (e.g. WS + SSE redundancy on the same client,
* or two devices sharing one signing key) is preserved: every bridge
* connection still fetches + pushes the blob — each push records its own
* timestamp — so each connected bridge gets the frame. Only the *poll*
* fetch is filtered, not the bridge fetches themselves.
*
* @see Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
*/
const DEFAULT_GRACE_MS = 60_000;
export interface BridgeDeliveryLogOptions {
/**
* How long a `(address, msgId)` mark suppresses inbox-poll delivery.
* Defaults to 60_000ms — twice the default `pollIntervalMs` of the
* `@shade/inbox` orchestrator, so a poll cycle that races a bridge
* push always sees the mark, but a stuck recipient still gets the
* blob via poll within ~minutes.
*/
graceMs?: number;
/**
* Maximum entries per address. Bounds memory under a busy address.
* Oldest entries (by recorded timestamp) are evicted first. Default
* 8192 — comfortably above any realistic backlog.
*/
maxPerAddress?: number;
}
export class BridgeDeliveryLog {
private readonly log = new Map<string, Map<string, number>>();
private readonly graceMs: number;
private readonly maxPerAddress: number;
constructor(options: BridgeDeliveryLogOptions = {}) {
this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
this.maxPerAddress = options.maxPerAddress ?? 8192;
}
/** Mark `(address, msgId)` as bridge-delivered at `now`. */
recordDelivered(address: string, msgId: string, now: number): void {
let inner = this.log.get(address);
if (!inner) {
inner = new Map();
this.log.set(address, inner);
}
inner.set(msgId, now);
// Lazy cleanup: drop entries past 2× grace so the map stays bounded
// without a separate timer. Bound by `maxPerAddress` as a fallback
// for pathological burst scenarios.
if (inner.size > this.maxPerAddress) {
const cutoff = now - this.graceMs * 2;
for (const [id, ts] of inner) {
if (ts < cutoff) inner.delete(id);
}
// Still over cap? Drop the oldest.
if (inner.size > this.maxPerAddress) {
const sorted = Array.from(inner.entries()).sort((a, b) => a[1] - b[1]);
const toDrop = sorted.slice(0, inner.size - this.maxPerAddress);
for (const [id] of toDrop) inner.delete(id);
}
}
}
/**
* Returns true if `(address, msgId)` was bridge-delivered within the
* grace window.
*/
isRecentlyDelivered(address: string, msgId: string, now: number): boolean {
const inner = this.log.get(address);
if (!inner) return false;
const ts = inner.get(msgId);
if (ts === undefined) return false;
if (now - ts > this.graceMs) {
inner.delete(msgId); // tombstone the stale entry
return false;
}
return true;
}
/**
* Filter `blobs` down to those not currently in the bridge-delivered
* grace window. Used by the inbox-fetch route to suppress duplicates.
*/
filterRecent<T extends { msgId: string }>(
address: string,
blobs: T[],
now: number,
): T[] {
const inner = this.log.get(address);
if (!inner || inner.size === 0) return blobs;
return blobs.filter((b) => {
const ts = inner.get(b.msgId);
if (ts === undefined) return true;
if (now - ts > this.graceMs) {
inner.delete(b.msgId);
return true;
}
return false;
});
}
/** Drop the entry for `(address, msgId)`. Called from blob-delete paths. */
forget(address: string, msgId: string): void {
this.log.get(address)?.delete(msgId);
}
/** Drop every entry for `address`. Called from address-delete paths. */
forgetAddress(address: string): void {
this.log.delete(address);
}
/** Test-only inspection. */
size(address: string): number {
return this.log.get(address)?.size ?? 0;
}
}