Hermodr leaves Odin
Hermodr is a browser lowering, not an all-seer organ. It lived here as Node inside a Rust repository, sharing a module directory with the coordinator, and until this week it ran its own provider ingress and accepted publications into a private store. The ingress went first on purpose: extracting it beforehand would have relocated a second acceptance authority and called that progress. Removed: the daemon, its provider subscription source and state stream, both test files, four lifecycle scripts, the persona avatar, the hermodr:start script, and src/odin/utils.cjs, whose only remaining consumer was Hermodr and which it used for one of six functions. src/odin/ falls from fourteen modules to eleven. The CommonJS surface falls from sixteen files to twelve. What is left is the coordinator, the Rust organs, and two adapters declaring contracts other repositories own. The plan document stays as the record of what left and why. Phase 5, static lowering, is Hermodr's business now. 5 tests pass, syntax check clean over twelve files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
20bacabf7c
commit
3f22fd1d76
13 changed files with 20 additions and 2173 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
|
|
@ -200,16 +200,26 @@ replaces the private store behind the existing port, the ingress and its options
|
|||
are gone, and `src/odin/` is down to twelve modules. Verified by unit tests;
|
||||
behaviour against a live provider over RUDP remains an operator step.
|
||||
|
||||
**Phase 3 — Publish the document contract.** `documents.cjs` becomes a package
|
||||
consumed by both Odin and Hermodr.
|
||||
**Phase 3 — Publish the document contract. Done.** The 45 definitions are
|
||||
`cultcache-ts`'s `defineSwarmDocuments`, verified byte-identical to what
|
||||
`documents.cjs` produced. Odin keeps a 26-line adapter under the name its
|
||||
consumers already call. `utils.cjs` left with Hermodr, which used one of its six
|
||||
functions.
|
||||
|
||||
**Phase 4 — Extract.** Move the daemon, state stream, tests, lifecycle scripts,
|
||||
and the Hermodr-only modules to `GameCult/Hermodr`. MIT, matching the rest of the
|
||||
chain, so a self-hosting creator may actually run it.
|
||||
**Phase 4 — Extract. Done.** `GameCult/Hermodr`, MIT, sixteen files. The three
|
||||
dependencies on Odin were resolved rather than carried: the catalog comes from
|
||||
CultLib, Idunn's health contract is declared locally over the CultLib transport
|
||||
with `sourceRuntimeId` `hermodr`, and `parseArgs` is seventeen lines of
|
||||
`args.cjs`. `src/odin/` is down from fourteen modules to eleven, and Odin's
|
||||
CommonJS surface from sixteen files to twelve.
|
||||
|
||||
**Phase 5 — Static lowering.** Library plus CLI in the new repository, runnable
|
||||
without starting the daemon. Requiring the daemon reintroduces the dependency
|
||||
static lowering exists to remove.
|
||||
**Phase 5 — Static lowering. Not started, and no longer this repository's
|
||||
business.** Library plus CLI in `GameCult/Hermodr`, runnable without starting
|
||||
the daemon. Requiring the daemon reintroduces the dependency static lowering
|
||||
exists to remove.
|
||||
|
||||
This document stays here as the record of what left Odin and why. The work it
|
||||
describes now happens in Hermodr.
|
||||
|
||||
## Verification
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
"sleipnir:start": "cargo run -p sleipnir-daemon --",
|
||||
"muninn:build": "cargo build -p muninn-daemon",
|
||||
"muninn:start": "cargo run -p muninn-daemon --",
|
||||
"hermodr:start": "node src/hermodr-daemon.cjs",
|
||||
"rust:test": "cargo test --workspace",
|
||||
"check": "node scripts/check-syntax.cjs",
|
||||
"test": "node --test test/*.test.cjs",
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
param(
|
||||
[string] $BaseUrl = $(if ($env:HERMODR_BASE_URL) { $env:HERMODR_BASE_URL } else { "http://127.0.0.1:8798" })
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$healthUrl = $BaseUrl.TrimEnd("/") + "/health"
|
||||
$response = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3
|
||||
|
||||
if ($response.authority -ne "lowering-only") {
|
||||
throw "Hermodr responded, but did not report lowering-only authority."
|
||||
}
|
||||
|
||||
Write-Host "Hermodr browser lowering responds at $BaseUrl; authority=$($response.authority). This is not daemon health or Verse discovery authority."
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
param(
|
||||
[string] $HostAddress = $(if ($env:HERMODR_BIND_HOST) { $env:HERMODR_BIND_HOST } else { "127.0.0.1" }),
|
||||
[int] $Port = $(if ($env:HERMODR_PORT) { [int]$env:HERMODR_PORT } else { 8798 }),
|
||||
[string] $OdinCultMeshUri = $(if ($env:HERMODR_ODIN_CULTMESH_URI) { $env:HERMODR_ODIN_CULTMESH_URI } elseif ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }),
|
||||
[string] $IdunnRudpHealth = $(if ($env:HERMODR_IDUNN_RUDP_HEALTH) { $env:HERMODR_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }),
|
||||
[string] $IdunnDaemon = "hermodr",
|
||||
[string] $IdunnHealthContract = "hermodr.cultnet-rudp-browser-lowering-health"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "restart-hermodr.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path."
|
||||
}
|
||||
|
||||
$stop = Join-Path $PSScriptRoot "stop-hermodr.ps1"
|
||||
$start = Join-Path $PSScriptRoot "start-hermodr.ps1"
|
||||
|
||||
if (Test-Path -LiteralPath $stop) {
|
||||
& $stop
|
||||
}
|
||||
|
||||
& $start -HostAddress $HostAddress -Port $Port -OdinCultMeshUri $OdinCultMeshUri -IdunnRudpHealth $IdunnRudpHealth -IdunnDaemon $IdunnDaemon -IdunnHealthContract $IdunnHealthContract -NoWindow
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
param(
|
||||
[string] $HostAddress = $(if ($env:HERMODR_BIND_HOST) { $env:HERMODR_BIND_HOST } else { "127.0.0.1" }),
|
||||
[int] $Port = $(if ($env:HERMODR_PORT) { [int]$env:HERMODR_PORT } else { 8798 }),
|
||||
[string] $OdinCultMeshUri = $(if ($env:HERMODR_ODIN_CULTMESH_URI) { $env:HERMODR_ODIN_CULTMESH_URI } elseif ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }),
|
||||
[string] $IdunnRudpHealth = $(if ($env:HERMODR_IDUNN_RUDP_HEALTH) { $env:HERMODR_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }),
|
||||
[string] $IdunnDaemon = "hermodr",
|
||||
[string] $IdunnHealthContract = "hermodr.cultnet-rudp-browser-lowering-health",
|
||||
[string] $StateDir = $(Join-Path $PSScriptRoot "..\scratch\Hermodr"),
|
||||
[switch] $NoWindow
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
foreach ($name in @(
|
||||
"HERMODR_ODIN_HTTP_BASE",
|
||||
"HERMODR_ODIN_WS_BASE",
|
||||
"HERMODR_ODIN_STATE_PATH",
|
||||
"HERMODR_SLEIPNIR_COMMAND_RUDP",
|
||||
"HERMODR_SLEIPNIR_COMMAND_ENDPOINT",
|
||||
"HERMODR_MUNINN_RUDP",
|
||||
"HERMODR_AETHERIA_COMMAND_RUDP",
|
||||
"HERMODR_ODIN_CULTMESH_STORE",
|
||||
"ODIN_CULTMESH_STORE"
|
||||
)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
|
||||
throw "$name was removed. Hermodr lowers Odin/CultMesh state for browsers and may only publish typed CultMesh command documents."
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:HERMODR_COMMAND_STORE)) {
|
||||
throw "HERMODR_COMMAND_STORE was removed. Hermodr publishes typed commands through provider-advertised CultMesh routes and records visible witnesses in Odin's CultMesh cache."
|
||||
}
|
||||
|
||||
$node = "node"
|
||||
$Hermodr = Join-Path $PSScriptRoot "..\src\hermodr-daemon.cjs"
|
||||
$arguments = @(
|
||||
$Hermodr,
|
||||
"--host", $HostAddress,
|
||||
"--port", $Port,
|
||||
"--odin-cultmesh-uri", $OdinCultMeshUri
|
||||
)
|
||||
if (-not [string]::IsNullOrWhiteSpace($IdunnRudpHealth)) {
|
||||
$arguments += @(
|
||||
"--idunn-rudp-health", $IdunnRudpHealth,
|
||||
"--idunn-daemon", $IdunnDaemon,
|
||||
"--idunn-health-contract", $IdunnHealthContract
|
||||
)
|
||||
}
|
||||
|
||||
if ($NoWindow) {
|
||||
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
|
||||
$proc = Start-Process -FilePath $node -ArgumentList $arguments -WindowStyle Hidden -PassThru
|
||||
$proc.Id | Set-Content -Encoding ASCII -LiteralPath (Join-Path $StateDir "hermodr.pid")
|
||||
Write-Host "Started Hermodr browser lowering at http://${HostAddress}:${Port}/"
|
||||
return
|
||||
}
|
||||
|
||||
& $node @arguments
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
param(
|
||||
[string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\Hermodr")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$pidPath = Join-Path $StateDir "hermodr.pid"
|
||||
if (-not (Test-Path $pidPath)) {
|
||||
Write-Host "No Hermodr PID file found at $pidPath."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$HermodrPid = (Get-Content -Raw $pidPath).Trim()
|
||||
if ($HermodrPid -match "^\d+$") {
|
||||
$process = Get-Process -Id ([int] $HermodrPid) -ErrorAction SilentlyContinue
|
||||
if ($process) {
|
||||
Stop-Process -Id $process.Id
|
||||
Write-Host "Stopped Hermodr PID $HermodrPid."
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $pidPath -Force
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,154 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Provider state subscription for the browser lowering.
|
||||
//
|
||||
// Hermodr previously ran its own provider-session ingress and accepted
|
||||
// publications into a private store, which made it a required ingress rather
|
||||
// than a bridge: a surface reached a browser only if its provider bound to
|
||||
// Hermodr specifically. Providers own their state. Odin publishes where to find
|
||||
// it. This source resolves a provider through the catalog and reads that
|
||||
// provider directly, exactly as the command path already routes intent.
|
||||
//
|
||||
// It presents the same narrow port the previous store did -
|
||||
// forProvider(id) -> { latest, watch, watchLifecycle } - so
|
||||
// HermodrStateStreamRegistry, its sequence numbers, and its stale/reconnected
|
||||
// transitions are untouched.
|
||||
//
|
||||
// readDocument is injected. That keeps the polling and lifecycle semantics
|
||||
// testable without a live Verse, and keeps CultMesh transport out of this file.
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
class ProviderSubscriptionSource {
|
||||
constructor(readDocument, options = {}) {
|
||||
if (typeof readDocument !== "function") {
|
||||
throw new Error("Provider subscription source requires a readDocument(providerId, schemaId, recordKey) function.");
|
||||
}
|
||||
this.readDocument = readDocument;
|
||||
this.pollIntervalMs = Number.isFinite(options.pollIntervalMs) && options.pollIntervalMs > 0
|
||||
? options.pollIntervalMs
|
||||
: DEFAULT_POLL_INTERVAL_MS;
|
||||
this.setInterval = options.setInterval || setInterval;
|
||||
this.clearInterval = options.clearInterval || clearInterval;
|
||||
this.onError = options.onError || (() => {});
|
||||
this.entries = new Map();
|
||||
this.sequence = 0;
|
||||
}
|
||||
|
||||
forProvider(providerId) {
|
||||
return {
|
||||
latest: (schemaId, recordKey) => this.latest(providerId, schemaId, recordKey),
|
||||
watch: (schemaId, recordKey, callback) => this.watch(providerId, schemaId, recordKey, callback),
|
||||
watchLifecycle: (schemaId, recordKey, callback) => this.watchLifecycle(providerId, schemaId, recordKey, callback),
|
||||
};
|
||||
}
|
||||
|
||||
async latest(providerId, schemaId, recordKey) {
|
||||
const value = await this.readDocument(providerId, schemaId, recordKey);
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(`Provider ${providerId} published no ${schemaId}:${recordKey}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
watch(providerId, schemaId, recordKey, callback) {
|
||||
return this.watchLifecycle(providerId, schemaId, recordKey, (event) => {
|
||||
if (event.kind !== "withdrawn") callback(event.value);
|
||||
});
|
||||
}
|
||||
|
||||
watchLifecycle(providerId, schemaId, recordKey, callback) {
|
||||
const key = keyOf(providerId, schemaId, recordKey);
|
||||
let entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
providerId,
|
||||
schemaId,
|
||||
recordKey,
|
||||
listeners: new Set(),
|
||||
value: undefined,
|
||||
present: false,
|
||||
timer: null,
|
||||
polling: false,
|
||||
};
|
||||
this.entries.set(key, entry);
|
||||
entry.timer = this.setInterval(() => { this.poll(key); }, this.pollIntervalMs);
|
||||
if (typeof entry.timer?.unref === "function") entry.timer.unref();
|
||||
}
|
||||
|
||||
entry.listeners.add(callback);
|
||||
if (entry.present) {
|
||||
callback({ kind: "snapshot", sequence: ++this.sequence, value: entry.value });
|
||||
}
|
||||
// First listener on a cold entry still needs a value without waiting a full
|
||||
// interval. Subsequent listeners piggyback on the shared poll.
|
||||
this.poll(key);
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
entry.listeners.delete(callback);
|
||||
if (entry.listeners.size === 0) {
|
||||
this.clearInterval(entry.timer);
|
||||
this.entries.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async poll(key) {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry || entry.polling) return;
|
||||
entry.polling = true;
|
||||
try {
|
||||
let value;
|
||||
try {
|
||||
value = await this.readDocument(entry.providerId, entry.schemaId, entry.recordKey);
|
||||
} catch (error) {
|
||||
// A read failure is not a withdrawal. The provider may be restarting,
|
||||
// and claiming withdrawal would let a transport hiccup look like the
|
||||
// provider retracting state it still holds.
|
||||
this.onError(error, { providerId: entry.providerId, schemaId: entry.schemaId, recordKey: entry.recordKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.entries.has(key)) return;
|
||||
|
||||
const missing = value === undefined || value === null;
|
||||
if (missing) {
|
||||
if (entry.present) {
|
||||
entry.present = false;
|
||||
entry.value = undefined;
|
||||
this.emit(entry, { kind: "withdrawn", sequence: ++this.sequence, reason: "withdrawn" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.present && equivalent(entry.value, value)) return;
|
||||
|
||||
const kind = entry.present ? "update" : "snapshot";
|
||||
entry.present = true;
|
||||
entry.value = value;
|
||||
this.emit(entry, { kind, sequence: ++this.sequence, value });
|
||||
} finally {
|
||||
entry.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
emit(entry, event) {
|
||||
for (const listener of entry.listeners) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(providerId, schemaId, recordKey) {
|
||||
if (!String(providerId || "").trim() || !String(schemaId || "").trim() || !String(recordKey || "").trim()) {
|
||||
throw new Error("Provider subscription requires providerId, schemaId, and recordKey.");
|
||||
}
|
||||
return `${providerId}\u001f${schemaId}\u001f${recordKey}`;
|
||||
}
|
||||
|
||||
function equivalent(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
module.exports = { ProviderSubscriptionSource, DEFAULT_POLL_INTERVAL_MS };
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
class HermodrStateStreamRegistry {
|
||||
constructor(source) { this.source = source; this.entries = new Map(); }
|
||||
acquire(selection, listener) {
|
||||
const key = `${selection.schemaId}\u001f${selection.recordKey}`;
|
||||
let entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
entry = { listeners: new Set([listener]), sequence: 0, stale: false, unsubscribe: null };
|
||||
entry.unsubscribe = this.source.forProvider(selection.providerId).watchLifecycle(selection.schemaId, selection.recordKey, event => {
|
||||
const type = event.kind === "withdrawn" ? "stale" : entry.stale ? "reconnected" : event.kind;
|
||||
entry.stale = type === "stale";
|
||||
const lowered = { type, sequence: ++entry.sequence, source: selection,
|
||||
...(event.value === undefined ? {} : { value: event.value }),
|
||||
...(event.reason ? { reason: event.reason } : {}) };
|
||||
for (const subscriber of entry.listeners) subscriber(lowered);
|
||||
});
|
||||
this.entries.set(key, entry);
|
||||
} else {
|
||||
entry.listeners.add(listener);
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
entry.listeners.delete(listener);
|
||||
if (entry.listeners.size === 0) { entry.unsubscribe(); this.entries.delete(key); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HermodrStateStreamRegistry };
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
const key = arg.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
parsed[key] = true;
|
||||
} else {
|
||||
parsed[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseObjectDocument(label) {
|
||||
return {
|
||||
parse(value) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function positiveNumber(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
|
||||
function stableId(value) {
|
||||
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.round(value)));
|
||||
}
|
||||
|
||||
function tailTextFile(filePath, maxBytes) {
|
||||
const stat = fs.statSync(filePath);
|
||||
const length = Math.min(stat.size, maxBytes);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
fs.readSync(fd, buffer, 0, length, stat.size - length);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clampNumber,
|
||||
parseArgs,
|
||||
parseObjectDocument,
|
||||
positiveNumber,
|
||||
stableId,
|
||||
tailTextFile,
|
||||
};
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
createBrowserCatalog,
|
||||
findProviderCdnRoute,
|
||||
hermodrRudpPeerOptions,
|
||||
normalizeProviderAdvertisement,
|
||||
normalizeSurfaceState,
|
||||
surfaceRecordKeys,
|
||||
} = require("../src/hermodr-daemon.cjs");
|
||||
|
||||
test("Hermodr outbound RUDP peers bind beyond loopback", () => {
|
||||
const options = hermodrRudpPeerOptions();
|
||||
assert.equal(options.bindHost, "0.0.0.0");
|
||||
assert.equal(options.connectTimeoutMs, 2_000);
|
||||
});
|
||||
|
||||
const provider = normalizeProviderAdvertisement({
|
||||
providerId: "fixture.game",
|
||||
title: "Fixture game",
|
||||
surfaces: [
|
||||
{ surfaceId: "fixture.menu", surfaceKind: "menu", recordRef: "cultmesh://fixture/surfaces/menu" },
|
||||
{ surfaceId: "fixture.world", surfaceKind: "interactive-world", recordRef: "cultmesh://fixture/surfaces/world" },
|
||||
],
|
||||
routes: [{
|
||||
id: "fixture-cdn",
|
||||
uri: "rudp://127.0.0.1:3000",
|
||||
tags: ["cultmesh-cdn", "asset_blob"],
|
||||
}],
|
||||
});
|
||||
|
||||
test("Hermodr preserves every advertised surface and its semantic kind", () => {
|
||||
const catalog = createBrowserCatalog([provider], { odinCultMeshUri: "cultmesh://odin/providers" });
|
||||
assert.deepEqual(catalog.surfaces.map(surface => surface.surfaceId), ["fixture.menu", "fixture.world"]);
|
||||
assert.equal(catalog.surfaces[1].surfaceKind, "interactive-world");
|
||||
assert.equal(catalog.providers[0].surfaces[1].recordRef, "cultmesh://fixture/surfaces/world");
|
||||
});
|
||||
|
||||
test("surface reads prefer the provider-advertised record reference", () => {
|
||||
const catalog = createBrowserCatalog([provider]);
|
||||
assert.equal(surfaceRecordKeys(catalog, "fixture.game", "fixture.world")[0], "cultmesh://fixture/surfaces/world");
|
||||
});
|
||||
|
||||
test("surface-state normalization lowers MessagePack array records", () => {
|
||||
const state = normalizeSurfaceState([
|
||||
"gjallar.overview",
|
||||
"Gjallar",
|
||||
7,
|
||||
"2026-08-25T00:00:00Z",
|
||||
[
|
||||
"gjallar.overview.surface",
|
||||
["root", "dashboard", { title: "Gjallar" }, [], [], [], { mode: "weighted-bisect" }, {}],
|
||||
[],
|
||||
],
|
||||
], "gjallar.overview");
|
||||
|
||||
assert.equal(state.surface.id, "gjallar.overview.surface");
|
||||
assert.equal(state.surface.root.kind, "dashboard");
|
||||
assert.equal(state.surface.root.layout.mode, "weighted-bisect");
|
||||
});
|
||||
|
||||
test("CDN routing follows the asset URI provider instead of a product name", () => {
|
||||
const catalog = createBrowserCatalog([provider]);
|
||||
const route = findProviderCdnRoute(catalog, "cultmesh://fixture.game/assets/player");
|
||||
assert.equal(route.providerId, "fixture.game");
|
||||
assert.equal(route.endpoint, "rudp://127.0.0.1:3000");
|
||||
});
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { ProviderSubscriptionSource } = require("../src/hermodr-provider-subscription.cjs");
|
||||
|
||||
function harness(reads) {
|
||||
const state = { value: reads.shift(), calls: 0, cleared: 0 };
|
||||
let tick = null;
|
||||
const source = new ProviderSubscriptionSource(
|
||||
async () => {
|
||||
state.calls += 1;
|
||||
if (state.value instanceof Error) throw state.value;
|
||||
return state.value;
|
||||
},
|
||||
{
|
||||
setInterval: (fn) => { tick = fn; return { unref() {} }; },
|
||||
clearInterval: () => { state.cleared += 1; tick = null; },
|
||||
onError: () => {},
|
||||
},
|
||||
);
|
||||
return {
|
||||
source,
|
||||
state,
|
||||
set: (value) => { state.value = value; },
|
||||
poll: async () => { if (tick) tick(); await flush(); },
|
||||
hasTimer: () => tick !== null,
|
||||
};
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 4; i += 1) await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function collect(source, providerId = "erycina", schemaId = "gamecult.eve.surface_state.v1", recordKey = "erycina") {
|
||||
const events = [];
|
||||
const release = source.forProvider(providerId).watchLifecycle(schemaId, recordKey, (event) => events.push(event));
|
||||
return { events, release };
|
||||
}
|
||||
|
||||
test("first watch emits a snapshot of the provider's document", async () => {
|
||||
const h = harness([{ title: "Erycina" }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].kind, "snapshot");
|
||||
assert.deepEqual(events[0].value, { title: "Erycina" });
|
||||
});
|
||||
|
||||
test("an unchanged document emits nothing further", async () => {
|
||||
const h = harness([{ title: "Erycina" }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
await h.poll();
|
||||
await h.poll();
|
||||
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test("a changed document emits an update", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
h.set({ version: 2 });
|
||||
await h.poll();
|
||||
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[1].kind, "update");
|
||||
assert.deepEqual(events[1].value, { version: 2 });
|
||||
assert.ok(events[1].sequence > events[0].sequence);
|
||||
});
|
||||
|
||||
test("a document that disappears is withdrawn", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
h.set(null);
|
||||
await h.poll();
|
||||
|
||||
assert.equal(events.at(-1).kind, "withdrawn");
|
||||
assert.equal(events.at(-1).reason, "withdrawn");
|
||||
});
|
||||
|
||||
test("a document that returns after withdrawal emits a fresh snapshot", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
h.set(null);
|
||||
await h.poll();
|
||||
h.set({ version: 3 });
|
||||
await h.poll();
|
||||
|
||||
assert.equal(events.at(-1).kind, "snapshot");
|
||||
assert.deepEqual(events.at(-1).value, { version: 3 });
|
||||
});
|
||||
|
||||
test("a read failure is not a withdrawal", async () => {
|
||||
// A provider restart or a dropped packet must not look like the provider
|
||||
// retracting state it still holds. This is the invariant that stops a
|
||||
// transport hiccup from being rendered as authoritative absence.
|
||||
const h = harness([{ version: 1 }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
const before = events.length;
|
||||
|
||||
h.set(new Error("rudp timeout"));
|
||||
await h.poll();
|
||||
await h.poll();
|
||||
|
||||
assert.equal(events.length, before);
|
||||
assert.equal(events.at(-1).kind, "snapshot");
|
||||
});
|
||||
|
||||
test("state survives the failure and updates once reads recover", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const { events } = collect(h.source);
|
||||
await flush();
|
||||
h.set(new Error("rudp timeout"));
|
||||
await h.poll();
|
||||
h.set({ version: 9 });
|
||||
await h.poll();
|
||||
|
||||
assert.equal(events.at(-1).kind, "update");
|
||||
assert.deepEqual(events.at(-1).value, { version: 9 });
|
||||
});
|
||||
|
||||
test("releasing the last listener stops polling", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const { release } = collect(h.source);
|
||||
await flush();
|
||||
assert.ok(h.hasTimer());
|
||||
|
||||
release();
|
||||
|
||||
assert.equal(h.state.cleared, 1);
|
||||
assert.equal(h.hasTimer(), false);
|
||||
});
|
||||
|
||||
test("a second listener shares one poll and receives the current snapshot", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const first = collect(h.source);
|
||||
await flush();
|
||||
const callsAfterFirst = h.state.calls;
|
||||
|
||||
const second = collect(h.source);
|
||||
await flush();
|
||||
|
||||
assert.equal(second.events[0].kind, "snapshot");
|
||||
assert.deepEqual(second.events[0].value, { version: 1 });
|
||||
// The shared entry polls once per interval regardless of listener count.
|
||||
await h.poll();
|
||||
assert.ok(h.state.calls <= callsAfterFirst + 2);
|
||||
|
||||
first.release();
|
||||
assert.equal(h.state.cleared, 0, "the entry survives while one listener remains");
|
||||
second.release();
|
||||
assert.equal(h.state.cleared, 1);
|
||||
});
|
||||
|
||||
test("watch skips withdrawal events and yields values only", async () => {
|
||||
const h = harness([{ version: 1 }]);
|
||||
const values = [];
|
||||
h.source.forProvider("erycina").watch("s", "k", (value) => values.push(value));
|
||||
await flush();
|
||||
h.set(null);
|
||||
await h.poll();
|
||||
h.set({ version: 2 });
|
||||
await h.poll();
|
||||
|
||||
assert.deepEqual(values, [{ version: 1 }, { version: 2 }]);
|
||||
});
|
||||
|
||||
test("latest refuses to invent a document the provider does not publish", async () => {
|
||||
const h = harness([null]);
|
||||
await assert.rejects(
|
||||
() => h.source.forProvider("erycina").latest("gamecult.eve.surface_state.v1", "erycina"),
|
||||
/published no/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a subscription requires provider, schema, and record key", () => {
|
||||
const h = harness([{}]);
|
||||
assert.throws(() => h.source.forProvider("").watchLifecycle("s", "k", () => {}), /requires providerId/);
|
||||
assert.throws(() => h.source.forProvider("p").watchLifecycle("", "k", () => {}), /requires providerId/);
|
||||
assert.throws(() => h.source.forProvider("p").watchLifecycle("s", "", () => {}), /requires providerId/);
|
||||
});
|
||||
Loading…
Reference in a new issue