Merge remote-tracking branch 'origin/libby/write-receipt-text-blank-rules' into libby/rs-receipt-key-tests-per-frame
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m0s
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m0s
This commit is contained in:
commit
2f3b30ab85
7 changed files with 356 additions and 8 deletions
|
|
@ -254,7 +254,7 @@
|
|||
}
|
||||
],
|
||||
"scenarios": {
|
||||
"semantics": "Each scenario replays events in atMs order against a client that sent each write at its send event; a tick advances the clock without a frame. 'window' names the constant in vectors.constants that applies (server receipt -> RECEIPT_RESULT_WINDOW_MS, legacy -> LEGACY_RESULT_WINDOW_MS); a tick at or after the window settles unanswered writes as unconfirmed. expect gives the outcome the caller was told per write id; recorded, when present, is the write handle's later state. admitted/refused/unconfirmed are three distinct results; no scenario may settle admitted without a write_admitted frame carrying the write's id.",
|
||||
"semantics": "Each scenario replays events in atMs order against a client that sent each write at its send event; a tick advances the clock without a frame. An event is atMs plus exactly one of: send (a write goes out), frame (a decoded frame arrives), transportAck (delivery, never an answer; the tracker has no input for it), tick (the clock advances), sendFailed (the client's own send of that write failed), sessionEnd (the session ended). Any other key is a defect in the vectors or the runner. A window ends when a write's age reaches it, so an event at exactly sentAt+window finds the write already unconfirmed. 'window' names the constant in vectors.constants that applies (server receipt -> RECEIPT_RESULT_WINDOW_MS, legacy -> LEGACY_RESULT_WINDOW_MS); a tick at or after the window settles unanswered writes as unconfirmed. expect gives the outcome the caller was told per write id; recorded, when present, is the write handle's later state. admitted/refused/unconfirmed are three distinct results; no scenario may settle admitted without a write_admitted frame carrying the write's id.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "receipt_admits",
|
||||
|
|
@ -909,6 +909,266 @@
|
|||
"outcome": "admitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_v1_without_id_with_one_write_in_flight_refuses",
|
||||
"description": "An error.v1 whose messageId is nil names no write: like error.v0 it is attributable only because one write is unanswered.",
|
||||
"server": "receipt",
|
||||
"window": "RECEIPT_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 30,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.error.v1",
|
||||
"error": "message_invalid",
|
||||
"routingHint": null,
|
||||
"code": "message_invalid",
|
||||
"details": null,
|
||||
"messageId": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "refused",
|
||||
"code": "message_invalid"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_v1_without_id_with_two_writes_in_flight_is_unattributed",
|
||||
"description": "An error.v1 with a nil messageId settles neither of two unanswered writes; each ends unconfirmed at its window.",
|
||||
"server": "receipt",
|
||||
"window": "RECEIPT_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1",
|
||||
"w2"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 1,
|
||||
"send": "w2"
|
||||
},
|
||||
{
|
||||
"atMs": 30,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.error.v1",
|
||||
"error": "message_invalid",
|
||||
"routingHint": null,
|
||||
"code": "message_invalid",
|
||||
"details": null,
|
||||
"messageId": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"atMs": 5001,
|
||||
"tick": true
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "unconfirmed",
|
||||
"reason": "window_elapsed"
|
||||
},
|
||||
"w2": {
|
||||
"outcome": "unconfirmed",
|
||||
"reason": "window_elapsed"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "send_failed_is_unconfirmed_and_touches_no_other_write",
|
||||
"description": "The client's own send of w1 failed: w1 is unconfirmed local_send_failed, never refused; w2 is unaffected and settles on its own receipt.",
|
||||
"server": "receipt",
|
||||
"window": "RECEIPT_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1",
|
||||
"w2"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 1,
|
||||
"send": "w2"
|
||||
},
|
||||
{
|
||||
"atMs": 2,
|
||||
"sendFailed": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 20,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.write_admitted.v0",
|
||||
"messageId": "w2",
|
||||
"operation": "put",
|
||||
"schemaId": "note.v0",
|
||||
"recordKey": "note:1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "unconfirmed",
|
||||
"reason": "local_send_failed"
|
||||
},
|
||||
"w2": {
|
||||
"outcome": "admitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error_without_id_after_the_window_settles_nothing",
|
||||
"description": "Once w1 is unconfirmed no write is unanswered, so an error that names no write is attributed to nothing and records nothing on w1.",
|
||||
"server": "legacy",
|
||||
"window": "LEGACY_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 500,
|
||||
"tick": true
|
||||
},
|
||||
{
|
||||
"atMs": 600,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"error": "write_not_authorized",
|
||||
"routingHint": null,
|
||||
"code": "write_not_authorized",
|
||||
"details": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "unconfirmed",
|
||||
"reason": "window_elapsed"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "receipt_one_millisecond_inside_the_window_admits",
|
||||
"description": "A receipt 1 ms before the window ends settles the write admitted.",
|
||||
"server": "receipt",
|
||||
"window": "RECEIPT_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 4999,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.write_admitted.v0",
|
||||
"messageId": "w1",
|
||||
"operation": "put",
|
||||
"schemaId": "note.v0",
|
||||
"recordKey": "note:1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "admitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "receipt_at_the_exact_window_boundary_is_late",
|
||||
"description": "The window ends when the write's age reaches it: a receipt at exactly that instant finds w1 already unconfirmed and is recorded admitted_late.",
|
||||
"server": "receipt",
|
||||
"window": "RECEIPT_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 5000,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.write_admitted.v0",
|
||||
"messageId": "w1",
|
||||
"operation": "put",
|
||||
"schemaId": "note.v0",
|
||||
"recordKey": "note:1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "unconfirmed",
|
||||
"reason": "window_elapsed",
|
||||
"recorded": "admitted_late"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "send_after_session_end_is_unconfirmed_at_once",
|
||||
"description": "A write registered after the session ended can never be answered: it settles unconfirmed session_ended when sent, and the earlier write keeps its own receipt.",
|
||||
"server": "receipt",
|
||||
"window": "RECEIPT_RESULT_WINDOW_MS",
|
||||
"writes": [
|
||||
"w1",
|
||||
"w2"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"atMs": 0,
|
||||
"send": "w1"
|
||||
},
|
||||
{
|
||||
"atMs": 5,
|
||||
"frame": {
|
||||
"schemaVersion": "cultnet.write_admitted.v0",
|
||||
"messageId": "w1",
|
||||
"operation": "put",
|
||||
"schemaId": "note.v0",
|
||||
"recordKey": "note:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"atMs": 40,
|
||||
"sessionEnd": true
|
||||
},
|
||||
{
|
||||
"atMs": 50,
|
||||
"send": "w2"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"w1": {
|
||||
"outcome": "admitted"
|
||||
},
|
||||
"w2": {
|
||||
"outcome": "unconfirmed",
|
||||
"reason": "session_ended"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -917,6 +1177,13 @@
|
|||
"LEGACY_RESULT_WINDOW_MS": 500,
|
||||
"DEDUPE_RETENTION_MS": 600000
|
||||
},
|
||||
"counts": {
|
||||
"semantics": "How many of each a runner must have run. A runner that reads fewer (a parser that drops entries, a loop over nothing) fails instead of passing quietly; a runner that meets an event key outside scenarios.semantics fails too.",
|
||||
"wire": 7,
|
||||
"schemaInvalid": 7,
|
||||
"contentHashCases": 4,
|
||||
"scenarioCases": 25
|
||||
},
|
||||
"contentHash": {
|
||||
"definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase",
|
||||
"cases": [
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ A write settles as exactly one of:
|
|||
|
||||
- **admitted**: a `write_admitted` whose `messageId` is the write's.
|
||||
- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error.
|
||||
- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other.
|
||||
- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered; a write registered after the session ended settles this way when it is sent) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other.
|
||||
|
||||
**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window.
|
||||
**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. It ends when the write's age reaches it: an answer arriving at exactly that instant is late. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window.
|
||||
|
||||
**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
|
|||
|
||||
### Added
|
||||
|
||||
- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`.
|
||||
- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`.
|
||||
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
|
||||
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.
|
||||
|
|
|
|||
|
|
@ -1422,7 +1422,7 @@ function validateBinaryMessageRoot(
|
|||
if (candidate.schemaVersion !== schemaVersion) {
|
||||
throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`);
|
||||
}
|
||||
if (typeof candidate.messageId !== "string" || candidate.messageId.trim().length === 0) {
|
||||
if (typeof candidate.messageId !== "string" || isBlank(candidate.messageId)) {
|
||||
throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`);
|
||||
}
|
||||
|
||||
|
|
@ -1460,8 +1460,13 @@ function validateRawDocumentRecord(
|
|||
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
|
||||
}
|
||||
|
||||
/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */
|
||||
function isBlank(value: string): boolean {
|
||||
return /^\p{White_Space}*$/u.test(value);
|
||||
}
|
||||
|
||||
function requireNonEmptyString(input: unknown, instancePath: string): void {
|
||||
if (typeof input !== "string" || input.trim().length === 0) {
|
||||
if (typeof input !== "string" || isBlank(input)) {
|
||||
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1477,7 +1482,7 @@ function requireOptionalStringArray(input: unknown, instancePath: string): void
|
|||
if (typeof input === "undefined" || input === null) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || item.trim().length === 0)) {
|
||||
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || isBlank(item))) {
|
||||
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export class WriteTracker {
|
|||
readonly #windowMs: number;
|
||||
readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void;
|
||||
readonly #writes = new Map<string, Entry>();
|
||||
#ended = false;
|
||||
|
||||
constructor(options: WriteTrackerOptions) {
|
||||
this.#windowMs = options.windowMs;
|
||||
|
|
@ -58,7 +59,11 @@ export class WriteTracker {
|
|||
/** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */
|
||||
send(messageId: string, atMs: number): void {
|
||||
this.tick(atMs);
|
||||
if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } });
|
||||
if (this.#writes.has(messageId)) return;
|
||||
const entry: Entry = { sentAtMs: atMs, state: { outcome: "pending" } };
|
||||
this.#writes.set(messageId, entry);
|
||||
// On a session that already ended nothing can answer this write.
|
||||
if (this.#ended) this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
|
||||
}
|
||||
|
||||
/** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */
|
||||
|
|
@ -93,9 +98,10 @@ export class WriteTracker {
|
|||
}
|
||||
}
|
||||
|
||||
/** The session ended: every unanswered write settles unconfirmed. */
|
||||
/** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */
|
||||
sessionEnd(atMs: number): void {
|
||||
this.tick(atMs);
|
||||
this.#ended = true;
|
||||
for (const [messageId, entry] of this.#writes) {
|
||||
if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
|
|
@ -195,6 +197,38 @@ test("the replay runner passes every vector against this runtime", () => {
|
|||
assert.doesNotMatch(run.stdout, /^FAIL/m);
|
||||
});
|
||||
|
||||
// A runner that skips what it cannot read passes a file it did not replay. It must fail instead.
|
||||
test("the replay runner fails on an unknown event key and on a vector count the file does not declare", () => {
|
||||
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
|
||||
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
||||
const dir = mkdtempSync(join(tmpdir(), "receipt-vectors-"));
|
||||
const runWith = (edit: (vectors: any) => void) => {
|
||||
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
|
||||
edit(vectors);
|
||||
const file = join(dir, "vectors.json");
|
||||
writeFileSync(file, JSON.stringify(vectors));
|
||||
return spawnSync(process.execPath, [script, file], { encoding: "utf8" });
|
||||
};
|
||||
const typo = runWith((v) => { v.scenarios.cases[0].events[0] = { atMs: 0, sendfailed: "w1" }; });
|
||||
assert.equal(typo.status, 1, typo.stdout);
|
||||
assert.match(typo.stdout, /^FAIL scenario\/receipt_admits$/m);
|
||||
const dropped = runWith((v) => { v.scenarios.cases.pop(); });
|
||||
assert.equal(dropped.status, 1, dropped.stdout);
|
||||
assert.match(dropped.stdout, /^FAIL counts$/m);
|
||||
const undeclared = runWith((v) => { v.wire.push(v.wire[0]); });
|
||||
assert.equal(undeclared.status, 1, undeclared.stdout);
|
||||
});
|
||||
|
||||
test("a write sent after the session ended settles unconfirmed session_ended at once, and the earlier receipt stands", () => {
|
||||
const settled: [string, WriteSettlement][] = [];
|
||||
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
|
||||
tracker.send("w1", 0);
|
||||
tracker.frame(admitted("w1"), 5);
|
||||
tracker.sessionEnd(40);
|
||||
tracker.send("w2", 50);
|
||||
assert.deepEqual(settled, [["w1", { outcome: "admitted" }], ["w2", { outcome: "unconfirmed", reason: "session_ended" }]]);
|
||||
});
|
||||
|
||||
test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => {
|
||||
const settled: [string, WriteSettlement][] = [];
|
||||
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
|
||||
|
|
@ -227,3 +261,22 @@ test("a write whose own send failed settles local_send_failed even when its wind
|
|||
tracker.sendFailed("w1", 1000);
|
||||
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
|
||||
});
|
||||
|
||||
// Blank is Unicode White_Space (write-receipt.md, rule 5), not what String.prototype.trim() trims: trim() takes U+FEFF and
|
||||
// misses U+0085. Every frame decoder that rejects a blank text must agree with the servers that do.
|
||||
test("a blank messageId, record key or tag is exactly Unicode White_Space, not trim()", () => {
|
||||
const put = (document: Record<string, unknown>, messageId = "p1") => ({
|
||||
schemaVersion: "cultnet.document_put_raw.v0", messageId,
|
||||
document: { schemaId: "note.v0", recordKey: "note:1", storedAt: "2026-11-08T00:00:00Z", payloadEncoding: "messagepack", payload: Uint8Array.of(0x81, 0xa1, 0x61, 0x01), ...document },
|
||||
});
|
||||
for (const blank of ["\u0085", " ", " "]) {
|
||||
assert.throws(() => parseCultNetMessage(put({}, blank)), `messageId ${JSON.stringify(blank)}`);
|
||||
assert.throws(() => parseCultNetMessage(put({ recordKey: blank })), `recordKey ${JSON.stringify(blank)}`);
|
||||
assert.throws(() => parseCultNetMessage(put({ tags: [blank] })), `tag ${JSON.stringify(blank)}`);
|
||||
}
|
||||
for (const named of ["", "", "\u001c", ""]) {
|
||||
parseCultNetMessage(put({}, named));
|
||||
parseCultNetMessage(put({ recordKey: named }));
|
||||
parseCultNetMessage(put({ tags: [named] }));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ const {
|
|||
const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
||||
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
|
||||
|
||||
// The event actions scenarios.semantics names; anything else is refused, not skipped.
|
||||
const EVENT_KEYS = ["send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck"];
|
||||
|
||||
const results = [];
|
||||
const check = (name, run) => {
|
||||
try {
|
||||
|
|
@ -79,10 +82,13 @@ for (const scenario of vectors.scenarios.cases) {
|
|||
told.set(id, settlement);
|
||||
} });
|
||||
for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) {
|
||||
const actions = Object.keys(event).filter((key) => key !== "atMs");
|
||||
if (actions.length !== 1 || !EVENT_KEYS.includes(actions[0])) { problems.push(`event at ${event.atMs} has keys [${actions}], wanted atMs and exactly one of ${EVENT_KEYS}`); continue; }
|
||||
if ("send" in event) tracker.send(event.send, event.atMs);
|
||||
else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs);
|
||||
else if ("tick" in event) tracker.tick(event.atMs);
|
||||
else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs);
|
||||
else if ("sendFailed" in event) tracker.sendFailed(event.sendFailed, event.atMs);
|
||||
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
|
||||
}
|
||||
for (const [id, want] of Object.entries(scenario.expect)) {
|
||||
|
|
@ -99,6 +105,16 @@ for (const scenario of vectors.scenarios.cases) {
|
|||
});
|
||||
}
|
||||
|
||||
// A runner that read fewer vectors than the file declares has silently skipped some. (contentHash is not replayed here.)
|
||||
check("counts", () => {
|
||||
const ran = (prefix) => results.filter((r) => r.name.startsWith(prefix)).length;
|
||||
const loaded = { wire: vectors.wire.length, schemaInvalid: vectors.schemaInvalid.length, scenarioCases: vectors.scenarios.cases.length };
|
||||
const executed = { wire: ran("wire/"), schemaInvalid: ran("schemaInvalid/"), scenarioCases: ran("scenario/") };
|
||||
return Object.keys(executed)
|
||||
.filter((key) => loaded[key] !== vectors.counts[key] || executed[key] !== vectors.counts[key])
|
||||
.map((key) => `${key}: declared ${vectors.counts[key]}, loaded ${loaded[key]}, ran ${executed[key]}`);
|
||||
});
|
||||
|
||||
function sorted(value) {
|
||||
if (Array.isArray(value)) return value.map(sorted);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])]));
|
||||
|
|
|
|||
Loading…
Reference in a new issue