mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
feat(readState): add NIP-RS override layer format and parse (slice 1)
Implements the manual-unread override register algebra from the merged
NIP-RS spec (docs/nips/NIP-RS.md, merge commit 209536ade) as pure
functions in the format and snapshot layers. No manager or UI wiring —
slice 1 only; slices 2 and 3 depend on the types exported here.
Changes in readStateFormat.ts:
- escapeFrontierKey / unescapeFrontierKey: bijection for raw context IDs
that begin with ov_ or esc: (Reserved Namespace spec rule).
- isOverrideKey: classifies ov_s:, ov_c:, ov_b: wire keys.
- OverrideRegister interface: the (S, C, B) triple for one context.
- OverrideLiveness interface: evaluation result for slice 3 consumers.
- isOverrideActive: liveness predicate S>0 AND F<=B AND S>C (clear-wins,
mandatory per spec Tie Policy section).
- mergeOverrideRegisters: componentwise max() — commutative, associative,
idempotent; matches model.py::merge_reg_b semantics exactly.
- encodeOverrideGroup: canonical wire encoding (live three-key / tombstone
floor / virgin-omit rules per Mandatory Canonical Publication section).
- partitionOverrideGroups: group-first override validation required by
Content Validation — collects complete logical groups before any
per-entry processing; partial groups are dropped, frontier retained.
- ValidatedOverrideGroup discriminated union: live | floor.
- sanitizeContexts: extended to run group-first validation (step 1),
then per-entry sanitization on non-override keys (step 2), then fold
validated groups back as integers (step 3). Existing callers unaffected.
Changes in readStateSnapshot.ts:
- extractOverrideRegisters: reconstructs register map from a sanitized
flat contexts map (read-side counterpart of encodeOverrideGroup).
- mergeOverrideRegisterMaps: merges register maps from multiple blobs via
componentwise max() (register-level analogue of frontier merge).
New file readStateOverride.test.mjs (58 tests):
- escape/unescape bijection including double-escape and round-trip cases.
- isOverrideKey coverage for all three prefixes and negative cases.
- isOverrideActive: live, virgin, frontier-dominated, tie (clear-wins),
clear-exceeds-set, and tombstone-floor cases.
- mergeOverrideRegisters: componentwise max, commutativity, idempotence,
associativity.
- encodeOverrideGroup: all canonical shapes (live, dead, tie, frontier-
dominated, pre-compacted floor, virgin).
- partitionOverrideGroups: live group, tombstone floor, partial group
(s-only, s+c only), invalid value, negative value, invalid floor c,
multi-context with mixed validity.
- sanitizeContexts: live group, tombstone floor, partial-group dropped
with frontier retained, normal entries, escaped frontier key pass-through.
- extractOverrideRegisters: live group, floor, no overrides, plain record.
- mergeOverrideRegisterMaps: two-map, disjoint, empty, single, three-way.
- Round-trip: live register, tombstone floor, and virgin register.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
209536ade6
commit
72b1d4f368
@@ -35,6 +35,257 @@ export const READ_STATE_MAX_SLOTS = 8;
|
||||
export const MSG_PREFIX = "msg:";
|
||||
export const THREAD_PREFIX = "thread:";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reserved namespace — override key prefixes and escape marker
|
||||
//
|
||||
// NIP-RS spec (Reserved Namespace section): the `ov_` stem and `esc:` marker
|
||||
// are reserved for the manual-unread override layer. Any raw context ID that
|
||||
// starts with either prefix MUST be escaped before use as a frontier key.
|
||||
// Override counter keys (`ov_s:`, `ov_c:`, `ov_b:`) are NEVER escaped — they
|
||||
// are their own wire namespace and are not legitimate frontier keys.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OV_S_PREFIX = "ov_s:" as const;
|
||||
export const OV_C_PREFIX = "ov_c:" as const;
|
||||
export const OV_B_PREFIX = "ov_b:" as const;
|
||||
const OV_STEM = "ov_" as const;
|
||||
const ESC_PREFIX = "esc:" as const;
|
||||
|
||||
/** True for wire keys that are override counters (`ov_s:`, `ov_c:`, `ov_b:`). */
|
||||
export function isOverrideKey(key: string): boolean {
|
||||
return (
|
||||
key.startsWith(OV_S_PREFIX) ||
|
||||
key.startsWith(OV_C_PREFIX) ||
|
||||
key.startsWith(OV_B_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a raw context ID for use as a frontier key.
|
||||
* Prepends `esc:` when the raw ID begins with `ov_` or `esc:`.
|
||||
* No-op for the context shapes Buzz generates (UUID, msg:hex64, thread:hex64).
|
||||
*/
|
||||
export function escapeFrontierKey(rawCtx: string): string {
|
||||
if (rawCtx.startsWith(OV_STEM) || rawCtx.startsWith(ESC_PREFIX)) {
|
||||
return ESC_PREFIX + rawCtx;
|
||||
}
|
||||
return rawCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the raw context ID from a frontier wire key.
|
||||
* Strips exactly one leading `esc:` if present; never strips more.
|
||||
*/
|
||||
export function unescapeFrontierKey(wireKey: string): string {
|
||||
if (wireKey.startsWith(ESC_PREFIX)) {
|
||||
return wireKey.slice(ESC_PREFIX.length);
|
||||
}
|
||||
return wireKey;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override register — the (S, C, B) triple for a single context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A fully-validated override register for one context.
|
||||
*
|
||||
* A live override (`isOverrideActive === true`) carries all three counters.
|
||||
* A tombstone floor carries only `c` (S=0, B=0).
|
||||
* A virgin register (never activated) is represented by the absence of this
|
||||
* type entirely — it is omitted from the wire.
|
||||
*
|
||||
* Slice 2 (manager) and slice 3 (UI) use this type as the wire-in/wire-out
|
||||
* contract. Slices MUST NOT construct registers directly — use
|
||||
* `encodeOverrideGroup` for encoding and rely on `extractOverrideRegisters`
|
||||
* (snapshot.ts) for decoding.
|
||||
*/
|
||||
export interface OverrideRegister {
|
||||
/** Set counter S — incremented on each mark-unread. */
|
||||
readonly s: number;
|
||||
/** Clear counter C — incremented on each mark-read. */
|
||||
readonly c: number;
|
||||
/** Baseline B — effective frontier at the time of the most recent mark-unread. */
|
||||
readonly b: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of evaluating whether a context has an active manual-unread override.
|
||||
*
|
||||
* `active` means: `S > 0 AND F <= B AND S > C` (clear-wins tie policy).
|
||||
* `frontier` is the merged effective frontier for the context (used by the
|
||||
* unread verdict in the UI layer).
|
||||
*/
|
||||
export interface OverrideLiveness {
|
||||
readonly active: boolean;
|
||||
/** The merged effective frontier value passed to `isOverrideActive`. */
|
||||
readonly frontier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the liveness predicate from the spec (clear-wins, mandatory).
|
||||
*
|
||||
* override_active(S, C, B, F) = S > 0 AND F <= B AND S > C
|
||||
*
|
||||
* F is the merged effective frontier for the context — typically
|
||||
* `contexts.get(rawCtxId) ?? 0` after applying the hierarchical frontier rule.
|
||||
*/
|
||||
export function isOverrideActive(
|
||||
reg: OverrideRegister,
|
||||
frontier: number,
|
||||
): boolean {
|
||||
return reg.s > 0 && frontier <= reg.b && reg.s > reg.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componentwise `max()` merge of two override registers.
|
||||
* Commutative, associative, idempotent — same as the frontier max() merge.
|
||||
*/
|
||||
export function mergeOverrideRegisters(
|
||||
a: OverrideRegister,
|
||||
b: OverrideRegister,
|
||||
): OverrideRegister {
|
||||
return {
|
||||
s: Math.max(a.s, b.s),
|
||||
c: Math.max(a.c, b.c),
|
||||
b: Math.max(a.b, b.b),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical wire encoding of an override register for a given context.
|
||||
*
|
||||
* Applies the tombstone-floor / virgin-omit rules from the spec
|
||||
* (Mandatory Canonical Publication section) so publishers always emit
|
||||
* a correctly-shaped group.
|
||||
*
|
||||
* Returns a partial `contexts` patch to be merged into the blob before
|
||||
* serialization. Caller is responsible for including the frontier entry
|
||||
* for `rawCtxId` separately.
|
||||
*
|
||||
* @param rawCtxId Raw (unescaped) context ID — e.g. `"abc-channel-uuid"`.
|
||||
* @param reg Override register to encode.
|
||||
* @param frontier Current merged effective frontier for this context.
|
||||
*/
|
||||
export function encodeOverrideGroup(
|
||||
rawCtxId: string,
|
||||
reg: OverrideRegister,
|
||||
frontier: number,
|
||||
): Record<string, number> {
|
||||
const virgin = reg.s === 0 && reg.c === 0;
|
||||
if (virgin) {
|
||||
// Virgin registers are omitted entirely from the wire.
|
||||
return {};
|
||||
}
|
||||
const active = isOverrideActive(reg, frontier);
|
||||
if (active) {
|
||||
// Live override: publish all three keys.
|
||||
return {
|
||||
[`${OV_S_PREFIX}${rawCtxId}`]: reg.s,
|
||||
[`${OV_C_PREFIX}${rawCtxId}`]: reg.c,
|
||||
[`${OV_B_PREFIX}${rawCtxId}`]: reg.b,
|
||||
};
|
||||
}
|
||||
// Dead override: tombstone floor — only ov_c: with value max(S, C).
|
||||
return {
|
||||
[`${OV_C_PREFIX}${rawCtxId}`]: Math.max(reg.s, reg.c),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override group validation — used inside sanitizeContexts
|
||||
//
|
||||
// The spec requires that override entries be validated as a complete logical
|
||||
// group BEFORE any per-entry processing. This function partitions the raw
|
||||
// contexts map into (a) validated override groups and (b) everything else,
|
||||
// so that sanitizeContexts can apply group-first rules without mixing the two
|
||||
// validation paths.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A validated override group ready for merge.
|
||||
* `kind === "live"` → three-key group with all counters present.
|
||||
* `kind === "floor"` → tombstone, only `c` is meaningful (s=0, b=0).
|
||||
*/
|
||||
export type ValidatedOverrideGroup =
|
||||
| { kind: "live"; reg: OverrideRegister }
|
||||
| { kind: "floor"; c: number };
|
||||
|
||||
/**
|
||||
* Partition the raw contexts map into validated override groups and
|
||||
* non-override entries.
|
||||
*
|
||||
* Override entries that fail group validation are silently dropped (per spec:
|
||||
* "the entire override group is rejected"); the corresponding frontier entry
|
||||
* for `<ctx>` is retained in `nonOverride`.
|
||||
*
|
||||
* @returns
|
||||
* `overrides` — Map from raw context ID to validated group.
|
||||
* `nonOverride` — Raw contexts map with all `ov_*` keys removed (frontier
|
||||
* entries and escaped keys remain for standard processing).
|
||||
*/
|
||||
export function partitionOverrideGroups(contexts: Record<string, unknown>): {
|
||||
overrides: Map<string, ValidatedOverrideGroup>;
|
||||
nonOverride: Record<string, unknown>;
|
||||
} {
|
||||
// First pass: collect all ov_* entries by context suffix.
|
||||
const sMap = new Map<string, unknown>();
|
||||
const cMap = new Map<string, unknown>();
|
||||
const bMap = new Map<string, unknown>();
|
||||
const nonOverride: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(contexts)) {
|
||||
if (key.startsWith(OV_S_PREFIX)) {
|
||||
sMap.set(key.slice(OV_S_PREFIX.length), value);
|
||||
} else if (key.startsWith(OV_C_PREFIX)) {
|
||||
cMap.set(key.slice(OV_C_PREFIX.length), value);
|
||||
} else if (key.startsWith(OV_B_PREFIX)) {
|
||||
bMap.set(key.slice(OV_B_PREFIX.length), value);
|
||||
} else {
|
||||
nonOverride[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: validate each unique ctx suffix as a group.
|
||||
const ctxSuffixes = new Set([...sMap.keys(), ...cMap.keys(), ...bMap.keys()]);
|
||||
const overrides = new Map<string, ValidatedOverrideGroup>();
|
||||
|
||||
for (const ctx of ctxSuffixes) {
|
||||
const hasS = sMap.has(ctx);
|
||||
const hasC = cMap.has(ctx);
|
||||
const hasB = bMap.has(ctx);
|
||||
|
||||
const sVal = sMap.get(ctx);
|
||||
const cVal = cMap.get(ctx);
|
||||
const bVal = bMap.get(ctx);
|
||||
|
||||
const isUint32 = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295;
|
||||
|
||||
if (!hasS && hasC && !hasB) {
|
||||
// Tombstone floor: only ov_c: present — must be a valid uint32.
|
||||
if (!isUint32(cVal)) continue; // invalid — drop group silently
|
||||
overrides.set(ctx, { kind: "floor", c: cVal });
|
||||
} else if (hasS && hasC && hasB) {
|
||||
// Complete live group: all three keys must have valid uint32 values.
|
||||
if (!isUint32(sVal) || !isUint32(cVal) || !isUint32(bVal)) continue;
|
||||
overrides.set(ctx, {
|
||||
kind: "live",
|
||||
reg: { s: sVal, c: cVal, b: bVal },
|
||||
});
|
||||
} else {
|
||||
// Partial group or any other shape — reject the whole group (spec).
|
||||
// The frontier entry for this ctx remains in nonOverride; do nothing here.
|
||||
}
|
||||
}
|
||||
|
||||
return { overrides, nonOverride };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core validation and sanitization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export function maxReadAt(...markers: Array<number | null>): number | null {
|
||||
@@ -94,16 +345,45 @@ export function isValidBlob(obj: unknown): obj is ReadStateBlob {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate a raw contexts object from a decoded blob.
|
||||
*
|
||||
* Override entries (`ov_*` keys) are validated as complete logical groups
|
||||
* BEFORE any per-entry processing, per the spec's Content Validation section.
|
||||
* Partial groups are rejected (dropped) while their corresponding frontier
|
||||
* entry is retained. Validated override groups are re-encoded into the result
|
||||
* as raw entries so that the rest of the pipeline (merge, liveness) operates
|
||||
* on a flat integer map — the same type as frontier entries.
|
||||
*
|
||||
* Escaped frontier keys (`esc:…`) pass through unchanged; unescaping happens
|
||||
* at read time in the snapshot layer.
|
||||
*/
|
||||
export function sanitizeContexts(
|
||||
contexts: Record<string, unknown>,
|
||||
): Record<string, number> {
|
||||
// Step 1: partition override groups from the rest (group-first validation).
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(contexts);
|
||||
|
||||
// Step 2: sanitize non-override entries with the standard per-entry rules.
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(contexts)) {
|
||||
for (const [key, value] of Object.entries(nonOverride)) {
|
||||
if (new TextEncoder().encode(key).length > 256) continue;
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) continue;
|
||||
if (value < 0 || value > 4294967295) continue;
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
// Step 3: fold validated override groups back as integer entries.
|
||||
for (const [ctx, group] of overrides) {
|
||||
if (group.kind === "floor") {
|
||||
result[`${OV_C_PREFIX}${ctx}`] = group.c;
|
||||
} else {
|
||||
result[`${OV_S_PREFIX}${ctx}`] = group.reg.s;
|
||||
result[`${OV_C_PREFIX}${ctx}`] = group.reg.c;
|
||||
result[`${OV_B_PREFIX}${ctx}`] = group.reg.b;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
encodeOverrideGroup,
|
||||
escapeFrontierKey,
|
||||
isOverrideActive,
|
||||
isOverrideKey,
|
||||
mergeOverrideRegisters,
|
||||
OV_B_PREFIX,
|
||||
OV_C_PREFIX,
|
||||
OV_S_PREFIX,
|
||||
partitionOverrideGroups,
|
||||
sanitizeContexts,
|
||||
unescapeFrontierKey,
|
||||
} from "./readStateFormat.ts";
|
||||
|
||||
import {
|
||||
extractOverrideRegisters,
|
||||
mergeOverrideRegisterMaps,
|
||||
} from "./readStateSnapshot.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// escapeFrontierKey / unescapeFrontierKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("escapeFrontierKey_normalCtx_passesThrough", () => {
|
||||
assert.equal(escapeFrontierKey("some-channel-uuid"), "some-channel-uuid");
|
||||
});
|
||||
|
||||
test("escapeFrontierKey_ovPrefix_prepentsEsc", () => {
|
||||
assert.equal(escapeFrontierKey("ov_s:evil"), "esc:ov_s:evil");
|
||||
});
|
||||
|
||||
test("escapeFrontierKey_escPrefix_prepentsEsc", () => {
|
||||
assert.equal(escapeFrontierKey("esc:foo"), "esc:esc:foo");
|
||||
});
|
||||
|
||||
test("escapeFrontierKey_ovStem_prepentsEsc", () => {
|
||||
assert.equal(escapeFrontierKey("ov_c:something"), "esc:ov_c:something");
|
||||
});
|
||||
|
||||
test("unescapeFrontierKey_noEscPrefix_passesThrough", () => {
|
||||
assert.equal(unescapeFrontierKey("some-channel-uuid"), "some-channel-uuid");
|
||||
});
|
||||
|
||||
test("unescapeFrontierKey_singleEscPrefix_strips", () => {
|
||||
assert.equal(unescapeFrontierKey("esc:ov_s:evil"), "ov_s:evil");
|
||||
});
|
||||
|
||||
test("unescapeFrontierKey_doubleEscPrefix_stripsExactlyOne", () => {
|
||||
// Must NOT strip more than one esc: prefix per spec.
|
||||
assert.equal(unescapeFrontierKey("esc:esc:foo"), "esc:foo");
|
||||
});
|
||||
|
||||
test("escape_then_unescape_isIdentity", () => {
|
||||
const raw = "ov_s:tricky";
|
||||
assert.equal(unescapeFrontierKey(escapeFrontierKey(raw)), raw);
|
||||
});
|
||||
|
||||
test("escape_then_unescape_isIdentity_escPrefixed", () => {
|
||||
const raw = "esc:already-escaped";
|
||||
assert.equal(unescapeFrontierKey(escapeFrontierKey(raw)), raw);
|
||||
});
|
||||
|
||||
test("escape_then_unescape_isIdentity_normalCtx", () => {
|
||||
const raw = "msg:deadbeef";
|
||||
assert.equal(unescapeFrontierKey(escapeFrontierKey(raw)), raw);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isOverrideKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("isOverrideKey_ovS_returnsTrue", () => {
|
||||
assert.equal(isOverrideKey("ov_s:ctx"), true);
|
||||
});
|
||||
|
||||
test("isOverrideKey_ovC_returnsTrue", () => {
|
||||
assert.equal(isOverrideKey("ov_c:ctx"), true);
|
||||
});
|
||||
|
||||
test("isOverrideKey_ovB_returnsTrue", () => {
|
||||
assert.equal(isOverrideKey("ov_b:ctx"), true);
|
||||
});
|
||||
|
||||
test("isOverrideKey_frontierKey_returnsFalse", () => {
|
||||
assert.equal(isOverrideKey("some-channel"), false);
|
||||
});
|
||||
|
||||
test("isOverrideKey_escapedKey_returnsFalse", () => {
|
||||
// esc:ov_s:… is a FRONTIER key, not an override key.
|
||||
assert.equal(isOverrideKey("esc:ov_s:ctx"), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isOverrideActive — liveness predicate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("isOverrideActive_liveOverride_returnsTrue", () => {
|
||||
// S=2, C=1, B=100, F=50 → S>0 ∧ F<=B ∧ S>C
|
||||
assert.equal(isOverrideActive({ s: 2, c: 1, b: 100 }, 50), true);
|
||||
});
|
||||
|
||||
test("isOverrideActive_virginRegister_returnsFalse", () => {
|
||||
assert.equal(isOverrideActive({ s: 0, c: 0, b: 0 }, 0), false);
|
||||
});
|
||||
|
||||
test("isOverrideActive_frontierExceedsBaseline_returnsFalse", () => {
|
||||
// Frontier advance dominates: F=200 > B=100
|
||||
assert.equal(isOverrideActive({ s: 1, c: 0, b: 100 }, 200), false);
|
||||
});
|
||||
|
||||
test("isOverrideActive_frontierEqualsBaseline_returnsTrue", () => {
|
||||
// F <= B (equals case): still active
|
||||
assert.equal(isOverrideActive({ s: 1, c: 0, b: 100 }, 100), true);
|
||||
});
|
||||
|
||||
test("isOverrideActive_clearWinsTie_returnsFalse", () => {
|
||||
// S == C with S > 0 → clear-wins: inactive
|
||||
assert.equal(isOverrideActive({ s: 1, c: 1, b: 100 }, 50), false);
|
||||
});
|
||||
|
||||
test("isOverrideActive_clearCounter_exceedsSet_returnsFalse", () => {
|
||||
assert.equal(isOverrideActive({ s: 1, c: 2, b: 100 }, 50), false);
|
||||
});
|
||||
|
||||
test("isOverrideActive_tombstoneFloor_returnsFalse", () => {
|
||||
// Tombstone floor shape: s=0, c=k, b=0
|
||||
assert.equal(isOverrideActive({ s: 0, c: 5, b: 0 }, 0), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeOverrideRegisters — componentwise max()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("mergeOverrideRegisters_takesMaxPerComponent", () => {
|
||||
const a = { s: 3, c: 1, b: 50 };
|
||||
const b = { s: 1, c: 4, b: 80 };
|
||||
assert.deepEqual(mergeOverrideRegisters(a, b), { s: 3, c: 4, b: 80 });
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisters_commutative", () => {
|
||||
const a = { s: 5, c: 2, b: 10 };
|
||||
const b = { s: 2, c: 7, b: 30 };
|
||||
assert.deepEqual(mergeOverrideRegisters(a, b), mergeOverrideRegisters(b, a));
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisters_idempotent", () => {
|
||||
const a = { s: 3, c: 3, b: 100 };
|
||||
assert.deepEqual(mergeOverrideRegisters(a, a), a);
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisters_associative", () => {
|
||||
const a = { s: 1, c: 0, b: 10 };
|
||||
const b = { s: 0, c: 2, b: 20 };
|
||||
const c = { s: 3, c: 1, b: 5 };
|
||||
assert.deepEqual(
|
||||
mergeOverrideRegisters(mergeOverrideRegisters(a, b), c),
|
||||
mergeOverrideRegisters(a, mergeOverrideRegisters(b, c)),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encodeOverrideGroup — canonical wire encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("encodeOverrideGroup_virginRegister_returnsEmpty", () => {
|
||||
const patch = encodeOverrideGroup("ctx", { s: 0, c: 0, b: 0 }, 0);
|
||||
assert.deepEqual(patch, {});
|
||||
});
|
||||
|
||||
test("encodeOverrideGroup_liveOverride_returnsThreeKeys", () => {
|
||||
const patch = encodeOverrideGroup("ctx", { s: 2, c: 1, b: 100 }, 50);
|
||||
assert.deepEqual(patch, {
|
||||
[`${OV_S_PREFIX}ctx`]: 2,
|
||||
[`${OV_C_PREFIX}ctx`]: 1,
|
||||
[`${OV_B_PREFIX}ctx`]: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test("encodeOverrideGroup_deadOverride_returnsOnlyOvC", () => {
|
||||
// Dead: S=1, C=2 (clear-wins) → tombstone floor ov_c = max(1,2) = 2
|
||||
const patch = encodeOverrideGroup("ctx", { s: 1, c: 2, b: 100 }, 50);
|
||||
assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 2 });
|
||||
});
|
||||
|
||||
test("encodeOverrideGroup_tieRegister_returnsOnlyOvC", () => {
|
||||
// Tie S==C → clear-wins → tombstone floor
|
||||
const patch = encodeOverrideGroup("ctx", { s: 3, c: 3, b: 100 }, 50);
|
||||
assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 3 });
|
||||
});
|
||||
|
||||
test("encodeOverrideGroup_frontierDominated_returnsOnlyOvC", () => {
|
||||
// Frontier past baseline: live rule fails → tombstone floor
|
||||
const patch = encodeOverrideGroup("ctx", { s: 1, c: 0, b: 100 }, 200);
|
||||
assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 1 });
|
||||
});
|
||||
|
||||
test("encodeOverrideGroup_tombstoneFloorShape_returnsOnlyOvC", () => {
|
||||
// Pre-compacted floor: s=0, c=5, b=0 → dead → tombstone max(0,5)=5
|
||||
const patch = encodeOverrideGroup("ctx", { s: 0, c: 5, b: 0 }, 0);
|
||||
assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 5 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// partitionOverrideGroups — group-first validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("partitionOverrideGroups_liveGroup_validates", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 1);
|
||||
const group = overrides.get("channel:x");
|
||||
assert.equal(group?.kind, "live");
|
||||
assert.deepEqual(group?.reg, { s: 1, c: 0, b: 900 });
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_tombstoneFloor_validates", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_c:channel:x": 5,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 1);
|
||||
const group = overrides.get("channel:x");
|
||||
assert.equal(group?.kind, "floor");
|
||||
assert.equal(group?.c, 5);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_partialGroup_droppedFrontierRetained", () => {
|
||||
// Only ov_s without ov_c and ov_b → partial group → rejected
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0); // group rejected
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 }); // frontier retained
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_sConly_droppedFrontierRetained", () => {
|
||||
// ov_s + ov_c but no ov_b → partial group
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
"ov_c:channel:x": 0,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_invalidValue_groupDropped", () => {
|
||||
// ov_s has non-integer value → invalid → whole group dropped
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": "not-a-number",
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_negativeValue_groupDropped", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": -1,
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_ovCInvalidValue_tombstoneDropped", () => {
|
||||
// Tombstone floor with non-integer c
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_c:channel:x": 1.5,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_multipleContexts_allValidated", () => {
|
||||
const raw = {
|
||||
"ch:a": 100,
|
||||
"ov_s:ch:a": 1,
|
||||
"ov_c:ch:a": 0,
|
||||
"ov_b:ch:a": 90,
|
||||
"ch:b": 200,
|
||||
"ov_c:ch:b": 3, // tombstone floor for ch:b
|
||||
"ch:c": 300,
|
||||
"ov_s:ch:c": 1, // partial — only s, no c/b → rejected
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 2); // ch:a and ch:b, ch:c rejected
|
||||
assert.equal(overrides.get("ch:a")?.kind, "live");
|
||||
assert.equal(overrides.get("ch:b")?.kind, "floor");
|
||||
assert.equal(overrides.has("ch:c"), false);
|
||||
// Frontier entries all retained
|
||||
assert.equal(nonOverride["ch:a"], 100);
|
||||
assert.equal(nonOverride["ch:b"], 200);
|
||||
assert.equal(nonOverride["ch:c"], 300);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitizeContexts — end-to-end with override group validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("sanitizeContexts_liveGroup_passesThrough", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
};
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["channel:x"], 1000);
|
||||
assert.equal(result[`${OV_S_PREFIX}channel:x`], 1);
|
||||
assert.equal(result[`${OV_C_PREFIX}channel:x`], 0);
|
||||
assert.equal(result[`${OV_B_PREFIX}channel:x`], 900);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_tombstoneFloor_passesThrough", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_c:channel:x": 5,
|
||||
};
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["channel:x"], 1000);
|
||||
assert.equal(result[`${OV_C_PREFIX}channel:x`], 5);
|
||||
assert.equal(result[`${OV_S_PREFIX}channel:x`], undefined);
|
||||
assert.equal(result[`${OV_B_PREFIX}channel:x`], undefined);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_partialGroup_droppedFrontierRetained", () => {
|
||||
// Partial group rejected; frontier entry kept
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
};
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["channel:x"], 1000);
|
||||
assert.equal(result[`${OV_S_PREFIX}channel:x`], undefined);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_normalEntriesUnaffected", () => {
|
||||
const raw = {
|
||||
"ch:normal": 5000,
|
||||
"msg:abc": 3000,
|
||||
"thread:def": 2000,
|
||||
};
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["ch:normal"], 5000);
|
||||
assert.equal(result["msg:abc"], 3000);
|
||||
assert.equal(result["thread:def"], 2000);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_invalidFrontierEntry_dropped", () => {
|
||||
const raw = {
|
||||
"good:key": 100,
|
||||
"bad:key": "not-a-number",
|
||||
};
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["good:key"], 100);
|
||||
assert.equal(result["bad:key"], undefined);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_escapedFrontierKey_passesThrough", () => {
|
||||
// esc: prefixed frontier keys must pass through unchanged
|
||||
const raw = { "esc:ov_s:suspicious-ctx": 1000 };
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["esc:ov_s:suspicious-ctx"], 1000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractOverrideRegisters — from sanitized flat map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("extractOverrideRegisters_liveGroup_reconstructsRegister", () => {
|
||||
const contexts = new Map([
|
||||
["channel:x", 1000],
|
||||
[`${OV_S_PREFIX}channel:x`, 2],
|
||||
[`${OV_C_PREFIX}channel:x`, 1],
|
||||
[`${OV_B_PREFIX}channel:x`, 900],
|
||||
]);
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.equal(regs.size, 1);
|
||||
assert.deepEqual(regs.get("channel:x"), { s: 2, c: 1, b: 900 });
|
||||
});
|
||||
|
||||
test("extractOverrideRegisters_tombstoneFloor_reconstructsRegister", () => {
|
||||
const contexts = new Map([
|
||||
["channel:x", 1000],
|
||||
[`${OV_C_PREFIX}channel:x`, 5],
|
||||
]);
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.equal(regs.size, 1);
|
||||
// Floor shape: s=0, c=5, b=0
|
||||
assert.deepEqual(regs.get("channel:x"), { s: 0, c: 5, b: 0 });
|
||||
});
|
||||
|
||||
test("extractOverrideRegisters_noOverrideKeys_returnsEmptyMap", () => {
|
||||
const contexts = new Map([["channel:x", 1000]]);
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.equal(regs.size, 0);
|
||||
});
|
||||
|
||||
test("extractOverrideRegisters_acceptsPlainRecord", () => {
|
||||
const contexts = {
|
||||
"channel:x": 1000,
|
||||
[`${OV_S_PREFIX}channel:x`]: 1,
|
||||
[`${OV_C_PREFIX}channel:x`]: 0,
|
||||
[`${OV_B_PREFIX}channel:x`]: 900,
|
||||
};
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.deepEqual(regs.get("channel:x"), { s: 1, c: 0, b: 900 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeOverrideRegisterMaps — across multiple blobs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("mergeOverrideRegisterMaps_twoMaps_takesComponentwiseMax", () => {
|
||||
const a = new Map([["ctx", { s: 3, c: 1, b: 50 }]]);
|
||||
const b = new Map([["ctx", { s: 1, c: 4, b: 80 }]]);
|
||||
const merged = mergeOverrideRegisterMaps(a, b);
|
||||
assert.deepEqual(merged.get("ctx"), { s: 3, c: 4, b: 80 });
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisterMaps_disjointContexts_bothPresent", () => {
|
||||
const a = new Map([["ctx:A", { s: 1, c: 0, b: 10 }]]);
|
||||
const b = new Map([["ctx:B", { s: 0, c: 2, b: 0 }]]);
|
||||
const merged = mergeOverrideRegisterMaps(a, b);
|
||||
assert.equal(merged.size, 2);
|
||||
assert.deepEqual(merged.get("ctx:A"), { s: 1, c: 0, b: 10 });
|
||||
assert.deepEqual(merged.get("ctx:B"), { s: 0, c: 2, b: 0 });
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisterMaps_emptyMaps_returnsEmpty", () => {
|
||||
const merged = mergeOverrideRegisterMaps(new Map(), new Map());
|
||||
assert.equal(merged.size, 0);
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisterMaps_singleMap_isIdentity", () => {
|
||||
const m = new Map([["ctx", { s: 2, c: 1, b: 50 }]]);
|
||||
const merged = mergeOverrideRegisterMaps(m);
|
||||
assert.deepEqual(merged.get("ctx"), { s: 2, c: 1, b: 50 });
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisterMaps_threeWayMerge_takesGlobalMax", () => {
|
||||
const a = new Map([["ctx", { s: 5, c: 0, b: 10 }]]);
|
||||
const b = new Map([["ctx", { s: 1, c: 3, b: 20 }]]);
|
||||
const c = new Map([["ctx", { s: 2, c: 1, b: 30 }]]);
|
||||
const merged = mergeOverrideRegisterMaps(a, b, c);
|
||||
assert.deepEqual(merged.get("ctx"), { s: 5, c: 3, b: 30 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip: encode → sanitize → extract produces original register
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("roundTrip_liveRegister_survivesEncodeAndExtract", () => {
|
||||
const reg = { s: 2, c: 1, b: 100 };
|
||||
const frontier = 50;
|
||||
const rawCtx = "channel-uuid-1234";
|
||||
|
||||
// Encode (as a publisher would)
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
// Simulate inclusion in a blob's contexts map (add frontier entry too)
|
||||
const rawContexts = { [rawCtx]: frontier, ...patch };
|
||||
// Sanitize (as a receiver would)
|
||||
const sanitized = sanitizeContexts(rawContexts);
|
||||
// Extract registers
|
||||
const regs = extractOverrideRegisters(sanitized);
|
||||
|
||||
assert.deepEqual(regs.get(rawCtx), reg);
|
||||
});
|
||||
|
||||
test("roundTrip_tombstoneFloor_survivesEncodeAndExtract", () => {
|
||||
// A dead register compacts to tombstone floor on encode
|
||||
const reg = { s: 0, c: 5, b: 0 }; // already a floor shape
|
||||
const frontier = 0;
|
||||
const rawCtx = "channel-uuid-5678";
|
||||
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
const rawContexts = { [rawCtx]: frontier, ...patch };
|
||||
const sanitized = sanitizeContexts(rawContexts);
|
||||
const regs = extractOverrideRegisters(sanitized);
|
||||
|
||||
// Floor is s=0, c=5, b=0
|
||||
assert.deepEqual(regs.get(rawCtx), { s: 0, c: 5, b: 0 });
|
||||
});
|
||||
|
||||
test("roundTrip_virginRegister_omittedFromWire", () => {
|
||||
const reg = { s: 0, c: 0, b: 0 };
|
||||
const frontier = 0;
|
||||
const rawCtx = "channel-uuid-virgin";
|
||||
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
assert.deepEqual(patch, {}); // nothing emitted
|
||||
const rawContexts = { [rawCtx]: frontier };
|
||||
const sanitized = sanitizeContexts(rawContexts);
|
||||
const regs = extractOverrideRegisters(sanitized);
|
||||
assert.equal(regs.has(rawCtx), false);
|
||||
});
|
||||
@@ -3,7 +3,12 @@ import type { RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
isValidBlob,
|
||||
isValidReadStateDTag,
|
||||
mergeOverrideRegisters,
|
||||
OV_B_PREFIX,
|
||||
OV_C_PREFIX,
|
||||
OV_S_PREFIX,
|
||||
sanitizeContexts,
|
||||
type OverrideRegister,
|
||||
type ReadStateBlob,
|
||||
} from "@/features/channels/readState/readStateFormat";
|
||||
|
||||
@@ -54,6 +59,73 @@ export async function parseReadStateEvent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all override registers from a sanitized contexts map.
|
||||
*
|
||||
* Returns a Map from raw context ID to `OverrideRegister`. A tombstone floor
|
||||
* (`ov_c:` only) is returned as `{ s: 0, c: <floor>, b: 0 }` — the same
|
||||
* shape as a dead register, which is correct: the floor's sole purpose is to
|
||||
* block counter reuse, and the liveness predicate evaluates to false for it.
|
||||
*
|
||||
* This is the read-side counterpart of `encodeOverrideGroup`: it reconstructs
|
||||
* the register from the flat integer map that `sanitizeContexts` produced.
|
||||
* The input MUST already have been through `sanitizeContexts` (so partial
|
||||
* groups are absent — they were rejected at validation time).
|
||||
*/
|
||||
export function extractOverrideRegisters(
|
||||
contexts: ReadonlyMap<string, number> | Record<string, number>,
|
||||
): Map<string, OverrideRegister> {
|
||||
// Collect s/c/b values per context suffix.
|
||||
const sVals = new Map<string, number>();
|
||||
const cVals = new Map<string, number>();
|
||||
const bVals = new Map<string, number>();
|
||||
|
||||
const iterate =
|
||||
contexts instanceof Map
|
||||
? [...contexts.entries()]
|
||||
: Object.entries(contexts);
|
||||
|
||||
for (const [key, value] of iterate) {
|
||||
if (key.startsWith(OV_S_PREFIX))
|
||||
sVals.set(key.slice(OV_S_PREFIX.length), value);
|
||||
else if (key.startsWith(OV_C_PREFIX))
|
||||
cVals.set(key.slice(OV_C_PREFIX.length), value);
|
||||
else if (key.startsWith(OV_B_PREFIX))
|
||||
bVals.set(key.slice(OV_B_PREFIX.length), value);
|
||||
}
|
||||
|
||||
const result = new Map<string, OverrideRegister>();
|
||||
const ctxSet = new Set([...sVals.keys(), ...cVals.keys(), ...bVals.keys()]);
|
||||
for (const ctx of ctxSet) {
|
||||
result.set(ctx, {
|
||||
s: sVals.get(ctx) ?? 0,
|
||||
c: cVals.get(ctx) ?? 0,
|
||||
b: bVals.get(ctx) ?? 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge override registers from multiple sanitized contexts maps.
|
||||
*
|
||||
* Applies componentwise `max()` across all sources for each context suffix,
|
||||
* returning a single Map from raw context ID to merged `OverrideRegister`.
|
||||
* This is the register-level analogue of the frontier `max()` merge.
|
||||
*/
|
||||
export function mergeOverrideRegisterMaps(
|
||||
...maps: Array<Map<string, OverrideRegister>>
|
||||
): Map<string, OverrideRegister> {
|
||||
const result = new Map<string, OverrideRegister>();
|
||||
for (const source of maps) {
|
||||
for (const [ctx, reg] of source) {
|
||||
const existing = result.get(ctx);
|
||||
result.set(ctx, existing ? mergeOverrideRegisters(existing, reg) : reg);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function mergeReadStateEvents(
|
||||
events: RelayEvent[],
|
||||
pubkey: string,
|
||||
|
||||
Reference in New Issue
Block a user