Squashed 'third_party/pi/' content from commit d981de122

git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:40:08 -04:00
commit 67b361f0e9
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import { readdir, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const sourceDirectory = resolve(packageDirectory, "src");
const IMPORT_SPECIFIER = /(?:import|export)\s+(?:type\s+)?(?:[^;]*?\sfrom\s*)?["']([^"']+)["']/gu;
describe("package boundary", () => {
test("does not depend on Pi packages or files outside Chord", async () => {
const manifest = JSON.parse(await readFile(resolve(packageDirectory, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@earendil-works/pi-"))).toEqual(
[],
);
const paths = (await readdir(sourceDirectory, { recursive: true })).filter((path) => path.endsWith(".ts")).sort();
const violations: string[] = [];
for (const path of paths) {
const file = resolve(sourceDirectory, path);
const source = await readFile(file, "utf8");
for (const match of source.matchAll(IMPORT_SPECIFIER)) {
const specifier = match[1]!;
if (specifier.startsWith("@earendil-works/pi-")) violations.push(`${path}: ${specifier}`);
if (specifier.startsWith(".") && !resolve(dirname(file), specifier).startsWith(`${sourceDirectory}/`)) {
violations.push(`${path}: ${specifier}`);
}
}
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,259 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { bundleFacetPackage, bundleFacets } from "../src/bundler.ts";
import { createFacetHost, defineFacet, defineService } from "../src/index.ts";
import {
createFacetBundleArtifactLoader,
createFacetBundleLoader,
readFacetBundleArtifact,
readFacetBundleManifest,
} from "../src/node.ts";
interface GenerationValue {
read(): string;
}
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const temporaryDirectories: string[] = [];
const GenerationValue = defineService<GenerationValue>("test.bundle.generation", { local: true });
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })));
});
describe("facet bundles", () => {
test("builds independent content-addressed entries and loads fresh reloadable generations", async () => {
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
temporaryDirectories.push(directory);
const sourceDirectory = join(directory, "src");
const outputDirectory = join(directory, "bundle");
await mkdir(sourceDirectory);
await writeFile(
join(sourceDirectory, "helper.ts"),
'export const decorate = (value: string): string => "generation:" + value;\n',
);
const entryPath = join(sourceDirectory, "entry.ts");
const presentationPath = join(sourceDirectory, "presentation.ts");
await writeGeneration(entryPath, "A");
await writeFile(presentationPath, 'export default { id: "bundle-presentation", setup() {} };\n');
const facetEntries = { presentation: presentationPath, worker: entryPath };
const firstBuild = await bundleFacets({
plugin: { id: "test-bundle", version: "1" },
entries: facetEntries,
outdir: outputDirectory,
sourceMap: true,
});
const firstEntry = firstBuild.manifest.entries.worker!;
expect(firstEntry.file).toMatch(/^facet-[a-f0-9]{12}-[A-Z0-9]+\.cjs$/u);
expect(firstEntry.sourceMap).toBe(`${firstEntry.file}.map`);
expect(firstEntry.externalImports).toEqual(["@earendil-works/chord"]);
expect(firstBuild.manifest.entries.presentation!.file).not.toBe(firstEntry.file);
expect((await readdir(outputDirectory)).filter((path) => path.endsWith(".cjs"))).toHaveLength(2);
const firstSource = await readFile(join(outputDirectory, firstEntry.file), "utf8");
expect(firstSource).toContain('require("@earendil-works/chord")');
expect(firstSource).toContain("module.exports");
expect((await readFile(firstBuild.manifestPath, "utf8")).endsWith("\n")).toBe(true);
const presentation = await createFacetBundleLoader({
manifestPath: firstBuild.manifestPath,
entry: "presentation",
}).load();
expect(presentation.facets.map(({ id }) => id)).toEqual(["bundle-presentation"]);
await presentation.dispose();
const artifact = await readFacetBundleArtifact({
manifestPath: firstBuild.manifestPath,
entry: "worker",
});
const materializedDirectory = join(directory, "materialized");
const transported = await createFacetBundleArtifactLoader({
artifact: structuredClone(artifact),
temporaryDirectory: materializedDirectory,
}).load();
expect(transported.facets.map(({ id }) => id)).toEqual(["bundle-provider"]);
expect(await readdir(materializedDirectory)).toHaveLength(1);
await transported.dispose();
expect(await readdir(materializedDirectory)).toEqual([]);
const secondBuild = await bundleFacets({
plugin: { id: "test-bundle", version: "1" },
entries: facetEntries,
outdir: outputDirectory,
sourceMap: true,
});
expect(secondBuild.manifest.entries.worker).toEqual(firstEntry);
const loader = createFacetBundleLoader({ manifestPath: secondBuild.manifestPath, entry: "worker" });
const loadedA = await loader.load();
const loadedACopy = await loader.load();
expect(loadedACopy.facets[0]).not.toBe(loadedA.facets[0]);
await loadedACopy.dispose();
let retained: GenerationValue | undefined;
const consumer = defineFacet({
id: "bundle-consumer",
setup(env) {
retained = env.use(GenerationValue);
},
});
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
expect(retained!.read()).toBe("generation:A");
await writeGeneration(entryPath, "B");
const thirdBuild = await bundleFacets({
plugin: { id: "test-bundle", version: "2" },
entries: facetEntries,
outdir: outputDirectory,
sourceMap: true,
});
expect(thirdBuild.manifest.entries.worker!.file).not.toBe(firstEntry.file);
const loadedB = await loader.load();
await host.reload(loadedB.facets);
await loadedA.dispose();
expect(retained!.read()).toBe("generation:B");
await host.dispose();
await loadedB.dispose();
});
test("loads host externals through the controlled CommonJS require", async () => {
const directory = await mkdtemp(join(packageDirectory, ".bundle-external-test-"));
temporaryDirectories.push(directory);
const externalPath = join(directory, "host.mjs");
const entryPath = join(directory, "entry.ts");
const outputDirectory = join(directory, "bundle");
await writeFile(externalPath, 'export const named = "host-named";\n');
await writeFile(
entryPath,
'import { named } from "@example/host";\n' +
'export const loadDynamic = () => import("@example/dynamic");\n' +
'if (named !== "host-named") throw new Error("external mismatch");\n' +
'export default { id: "external-facet", setup() {} };\n',
);
const result = await bundleFacets({
plugin: { id: "external-bundle" },
entries: { worker: entryPath },
external: ["@example/host", "@example/dynamic"],
outdir: outputDirectory,
});
const entry = result.manifest.entries.worker!;
const source = await readFile(join(outputDirectory, entry.file), "utf8");
expect(source).not.toContain("import(");
expect(source).toContain('require("@example/dynamic")');
const loaded = await createFacetBundleLoader({
manifestPath: result.manifestPath,
entry: "worker",
resolveExternal: (specifier) =>
specifier === "@example/host" || specifier === "@example/dynamic" ? pathToFileURL(externalPath) : undefined,
}).load();
expect(loaded.facets.map(({ id }) => id)).toEqual(["external-facet"]);
await loaded.dispose();
});
test("builds plugin packages from conventional and configured facet entries", async () => {
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
temporaryDirectories.push(directory);
const sourceDirectory = join(directory, "src");
await mkdir(sourceDirectory);
await Promise.all([
writeFile(
join(directory, "package.json"),
`${JSON.stringify({
name: "@example/conventional-plugin",
version: "1.2.3",
peerDependencies: { "@example/host": "^1.0.0" },
})}\n`,
),
writeFile(
join(sourceDirectory, "session.ts"),
'import "@example/host/plugin"; export default { id: "package-session", setup() {} };\n',
),
writeFile(join(sourceDirectory, "tui.ts"), 'export default { id: "package-tui", setup() {} };\n'),
writeFile(join(sourceDirectory, "contract.ts"), "export const ignored = true;\n"),
writeFile(join(sourceDirectory, "presentation.ts"), 'export default { id: "configured-tui", setup() {} };\n'),
]);
const conventional = await bundleFacetPackage({
packagePath: directory,
outdir: join(directory, "build"),
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts", browser: "src/browser.ts" },
});
expect(conventional.packageDirectory).toBe(directory);
expect(conventional.manifest.plugin).toEqual({ id: "@example/conventional-plugin", version: "1.2.3" });
expect(Object.keys(conventional.manifest.entries)).toEqual(["session", "tui"]);
expect(conventional.manifest.entries.session?.externalImports).toEqual(["@example/host/plugin"]);
expect(conventional.manifest.entries.tui?.sourceMap).toMatch(/\.cjs\.map$/u);
await writeFile(
join(directory, "package.json"),
`${JSON.stringify({
name: "@example/conventional-plugin",
version: "2.0.0",
chord: { facets: { session: false, tui: "src/presentation.ts" }, sourceMap: false },
})}\n`,
);
const configured = await bundleFacetPackage({
packagePath: join(directory, "package.json"),
outdir: join(directory, "build"),
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts" },
});
expect(Object.keys(configured.manifest.entries)).toEqual(["tui"]);
expect(configured.manifest.entries.tui?.sourceMap).toBeUndefined();
const loaded = await createFacetBundleLoader({
manifestPath: configured.manifestPath,
entry: "tui",
}).load();
expect(loaded.facets.map(({ id }) => id)).toEqual(["configured-tui"]);
await loaded.dispose();
});
test("rejects invalid plugin package entry configuration", async () => {
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
temporaryDirectories.push(directory);
await writeFile(
join(directory, "package.json"),
`${JSON.stringify({
name: "invalid-plugin",
version: "1.0.0",
chord: { facets: { tui: "../outside.ts" } },
})}\n`,
);
await expect(bundleFacetPackage({ packagePath: directory, outdir: join(directory, "build") })).rejects.toThrow(
"escapes the package directory",
);
});
test("rejects corrupt entries and invalid module exports", async () => {
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
temporaryDirectories.push(directory);
const entryPath = join(directory, "entry.ts");
const outputDirectory = join(directory, "bundle");
await writeFile(entryPath, "export default { id: 'missing-setup' };\n");
const result = await bundleFacets({
plugin: { id: "invalid-bundle" },
entries: { invalid: entryPath },
outdir: outputDirectory,
});
const loader = createFacetBundleLoader({ manifestPath: result.manifestPath, entry: "invalid" });
await expect(loader.load()).rejects.toThrow("has no setup function");
const manifest = await readFacetBundleManifest(result.manifestPath);
await writeFile(join(outputDirectory, manifest.entries.invalid!.file), "export default {};\n");
await expect(loader.load()).rejects.toThrow("integrity check failed");
});
});
async function writeGeneration(path: string, generation: string): Promise<void> {
await writeFile(
path,
`import "@earendil-works/chord";\n` +
`import { decorate } from "./helper.ts";\n` +
`const Value = { id: "test.bundle.generation", local: true };\n` +
`export default { id: "bundle-provider", setup(env) {\n` +
` env.provide(Value, { read() { return decorate(${JSON.stringify(generation)}); } });\n` +
`}};\n`,
);
}

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import {
awaitWithContext,
BACKGROUND_CONTEXT,
createContextKey,
TODO_CONTEXT,
withAbortSignal,
withCancel,
withContextValue,
withoutAbortSignal,
} from "../src/context/index.ts";
describe("Context", () => {
it("provides distinct empty root contexts", () => {
const key = createContextKey<string>("value");
expect(TODO_CONTEXT).not.toBe(BACKGROUND_CONTEXT);
expect(TODO_CONTEXT.abortSignal).toBeUndefined();
expect(TODO_CONTEXT.value(key)).toBeUndefined();
expect(String(BACKGROUND_CONTEXT)).toBe("[Context BACKGROUND_CONTEXT]");
expect(String(TODO_CONTEXT)).toBe("[Context TODO_CONTEXT]");
});
it("layers typed values without modifying parents", () => {
const firstKey = createContextKey<string>("first");
const secondKey = createContextKey<number>("second");
const first = withContextValue(firstKey, "one", BACKGROUND_CONTEXT);
const second = withContextValue(secondKey, 2, first);
const replaced = withContextValue(firstKey, "updated", second);
expect(BACKGROUND_CONTEXT.value(firstKey)).toBeUndefined();
expect(first.value(firstKey)).toBe("one");
expect(first.value(secondKey)).toBeUndefined();
expect(second.value(firstKey)).toBe("one");
expect(second.value(secondKey)).toBe(2);
expect(replaced.value(firstKey)).toBe("updated");
expect(second.value(firstKey)).toBe("one");
expect(String(replaced)).toBe("[Context BACKGROUND_CONTEXT].WithValue(first).WithValue(second).WithValue(first)");
});
it("inherits parent cancellation and isolates child cancellation", () => {
const parentController = new AbortController();
const parent = withAbortSignal(parentController.signal, BACKGROUND_CONTEXT);
const child = withCancel(parent);
const sibling = withCancel(parent);
const childListener = vi.fn();
child.context.abortSignal?.addEventListener("abort", childListener);
child.cancel("child");
expect(child.context.abortSignal?.aborted).toBe(true);
expect(child.context.abortSignal?.reason).toBe("child");
expect(sibling.context.abortSignal?.aborted).toBe(false);
expect(parent.abortSignal?.aborted).toBe(false);
expect(childListener).toHaveBeenCalledOnce();
parentController.abort("parent");
expect(sibling.context.abortSignal?.aborted).toBe(true);
expect(sibling.context.abortSignal?.reason).toBe("parent");
});
it("masks caller cancellation for mandatory cleanup", () => {
const controller = new AbortController();
const key = createContextKey<string>("value");
const context = withContextValue(key, "preserved", withAbortSignal(controller.signal, BACKGROUND_CONTEXT));
const cleanup = withoutAbortSignal(context);
controller.abort();
expect(context.abortSignal?.aborted).toBe(true);
expect(cleanup.abortSignal).toBeUndefined();
expect(cleanup.value(key)).toBe("preserved");
});
it("stops waiting when the invocation is cancelled", async () => {
const controller = new AbortController();
const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT);
let resolveWork!: (value: string) => void;
const work = new Promise<string>((resolve) => {
resolveWork = resolve;
});
const waiting = awaitWithContext(work, context);
const cancellation = new Error("cancelled");
controller.abort(cancellation);
await expect(waiting).rejects.toBe(cancellation);
resolveWork("completed later");
await expect(work).resolves.toBe("completed later");
await expect(awaitWithContext(Promise.resolve("completed"), BACKGROUND_CONTEXT)).resolves.toBe("completed");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,394 @@
import { describe, expect, test, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
type Context,
combineFacetLoaders,
createFacetHost,
createRemoteServiceBinding,
createStaticFacetLoader,
defineFacet,
defineService,
type FacetLoader,
} from "../src/index.ts";
import { createLoopbackServiceTransport } from "./helpers.ts";
interface GenerationValue {
read(context: Context): Promise<string>;
}
const LocalGenerationValue = defineService<GenerationValue>("test.experimental.local-generation-value", {
local: true,
});
const RemoteGenerationValue = defineService<GenerationValue>("test.experimental.remote-generation-value");
const firstFacet = defineFacet({ id: "first", setup() {} });
const secondFacet = defineFacet({ id: "second", setup() {} });
describe("facet loader", () => {
test("combines loaded facets in loader order and disposes generations in reverse", async () => {
const trace: string[] = [];
const first: FacetLoader = {
async load() {
trace.push("load first");
return {
facets: [firstFacet],
async dispose() {
trace.push("dispose first");
},
};
},
};
const second: FacetLoader = {
async load() {
trace.push("load second");
return {
facets: [secondFacet],
async dispose() {
trace.push("dispose second");
},
};
},
};
const loaded = await combineFacetLoaders([first, second]).load();
expect(loaded.facets).toEqual([firstFacet, secondFacet]);
await loaded.dispose();
await loaded.dispose();
expect(trace).toEqual(["load first", "load second", "dispose second", "dispose first"]);
});
test("cleans up loaded facets when a later loader fails", async () => {
const dispose = vi.fn(async () => {});
const failure = new Error("load failed");
const first: FacetLoader = {
async load() {
return { facets: [firstFacet], dispose };
},
};
const second: FacetLoader = {
async load() {
throw failure;
},
};
await expect(combineFacetLoaders([first, second]).load()).rejects.toBe(failure);
expect(dispose).toHaveBeenCalledOnce();
});
test("keeps local and RPC service handles stable when their provider facet reloads", async () => {
const trace: string[] = [];
let localValue: GenerationValue | undefined;
let generation = 0;
let reportReplacementStarted!: () => void;
const replacementStarted = new Promise<void>((resolve) => {
reportReplacementStarted = resolve;
});
let continueReplacement!: () => void;
const replacementCanContinue = new Promise<void>((resolve) => {
continueReplacement = resolve;
});
const consumer = defineFacet({
id: "consumer",
setup(env) {
trace.push("setup consumer");
localValue = env.use(LocalGenerationValue);
env.onActivate(async () => {
trace.push(`activate consumer:${await localValue!.read(BACKGROUND_CONTEXT)}`);
});
env.onDeactivate(() => {
trace.push("deactivate consumer");
});
},
});
const loader: FacetLoader = {
async load() {
const name = generation++ === 0 ? "A" : "B";
trace.push(`load ${name}`);
return {
facets: [
defineFacet({
id: "provider",
setup(env) {
trace.push(`setup provider ${name}`);
const implementation: GenerationValue = {
async read() {
return name;
},
};
env.provide(LocalGenerationValue, implementation);
env.provide(RemoteGenerationValue, implementation);
env.onActivate(async () => {
trace.push(`activate provider ${name}`);
if (name === "B") {
reportReplacementStarted();
await replacementCanContinue;
}
});
env.onDeactivate(() => {
trace.push(`deactivate provider ${name}`);
});
},
}),
],
async dispose() {
trace.push(`unload ${name}`);
},
};
},
};
const loadedA = await loader.load();
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
const originalLocalHandle = localValue!;
const localRead = originalLocalHandle.read;
const remoteServices = createRemoteServiceBinding({
services: [RemoteGenerationValue],
transport: createLoopbackServiceTransport(host.services),
});
const originalRemoteHandle = remoteServices.use(RemoteGenerationValue);
const remoteRead = originalRemoteHandle.read;
await remoteServices.ready(BACKGROUND_CONTEXT);
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(
host.reload([
defineFacet({
id: "provider",
setup(env) {
env.provide(LocalGenerationValue, {
async read() {
return "invalid";
},
});
},
}),
]),
).rejects.toThrow("Reloaded facet provider must preserve its service requirements and provisions");
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
const loadedB = await loader.load();
const reload = host.reload(loadedB.facets);
await replacementStarted;
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
continueReplacement();
await reload;
await loadedA.dispose();
expect(localValue).toBe(originalLocalHandle);
expect(remoteServices.use(RemoteGenerationValue)).toBe(originalRemoteHandle);
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
await remoteServices.dispose(BACKGROUND_CONTEXT);
await host.dispose();
await loadedB.dispose();
expect(trace).toEqual([
"load A",
"setup consumer",
"setup provider A",
"activate provider A",
"activate consumer:A",
"load B",
"setup provider B",
"activate provider B",
"deactivate provider A",
"unload A",
"deactivate consumer",
"deactivate provider B",
"unload B",
]);
});
test("rejects remote singleton member shape changes before reload cutover", async () => {
let retained: GenerationValue | undefined;
let providerDisposed = false;
const consumer = defineFacet({
id: "shape-consumer",
setup(env) {
retained = env.use(RemoteGenerationValue);
},
});
const provider = defineFacet({
id: "shape-provider",
setup(env) {
env.provide(RemoteGenerationValue, {
async read() {
return "A";
},
});
env.onDeactivate(() => {
providerDisposed = true;
});
},
});
const host = await createFacetHost({ facets: [consumer, provider] });
await expect(
host.reload([
defineFacet({
id: "shape-provider",
setup(env) {
env.provide(RemoteGenerationValue, {
async renamed() {
return "B";
},
} as unknown as GenerationValue);
},
}),
]),
).rejects.toThrow("replacement must preserve its member shape");
expect(providerDisposed).toBe(false);
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
await host.dispose();
expect(providerDisposed).toBe(true);
});
test("terminates the host when old cleanup fails after cutover", async () => {
const cleanupFailure = new Error("cleanup failed");
const provider = (name: string, failCleanup: boolean) =>
defineFacet({
id: "cleanup-provider",
setup(env) {
env.provide(RemoteGenerationValue, {
async read() {
return name;
},
});
env.onDeactivate(() => {
if (failCleanup) throw cleanupFailure;
});
},
});
const host = await createFacetHost({ facets: [provider("A", true)] });
await expect(host.reload([provider("B", false)])).rejects.toThrow("Facet reload failed after cutover");
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
await host.dispose();
});
test("cleans failed candidate activation in reverse dependency order", async () => {
const failure = new Error("consumer activation failed");
const trace: string[] = [];
const provider = (name: string) =>
defineFacet({
id: "ordered-provider",
setup(env) {
env.provide(RemoteGenerationValue, {
async read() {
return name;
},
});
env.onActivate(() => {
trace.push(`activate provider ${name}`);
});
env.onDeactivate(() => {
trace.push(`deactivate provider ${name}`);
});
},
});
const consumer = (name: string, fail: boolean) =>
defineFacet({
id: "ordered-consumer",
setup(env) {
env.use(RemoteGenerationValue);
env.onActivate(() => {
trace.push(`activate consumer ${name}`);
if (fail) throw failure;
});
env.onDeactivate(() => {
trace.push(`deactivate consumer ${name}`);
});
},
});
const host = await createFacetHost({ facets: [consumer("A", false), provider("A")] });
trace.length = 0;
await expect(host.reload([consumer("B", true), provider("B")])).rejects.toBe(failure);
expect(trace).toEqual([
"activate provider B",
"activate consumer B",
"deactivate consumer B",
"deactivate provider B",
]);
await host.dispose();
});
test("terminates the host when replacement publication fails after cutover", async () => {
const publicationFailure = new Error("publication failed");
const provider = (name: string) =>
defineFacet({
id: "publication-provider",
setup(env) {
env.provide(RemoteGenerationValue, {
async read() {
return name;
},
});
},
});
const host = await createFacetHost({ facets: [provider("A")] });
const subscription = host.services.subscribe(RemoteGenerationValue.id, "singleton", () => {
throw publicationFailure;
});
subscription.activate();
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
await host.dispose();
});
test("keeps the old generation active when replacement activation fails before cutover", async () => {
const failure = new Error("replacement activation failed");
const trace: string[] = [];
let retained: GenerationValue | undefined;
const consumer = defineFacet({
id: "terminal-consumer",
setup(env) {
retained = env.use(RemoteGenerationValue);
env.onDeactivate(() => {
trace.push("deactivate consumer");
});
},
});
const provider = (name: string, fail: boolean) =>
defineFacet({
id: "terminal-provider",
setup(env) {
env.provide(RemoteGenerationValue, {
async read() {
return name;
},
});
env.onActivate(() => {
trace.push(`activate ${name}`);
if (fail) throw failure;
});
env.onDeactivate(() => {
trace.push(`deactivate ${name}`);
});
},
});
const host = await createFacetHost({ facets: [consumer, provider("A", false)] });
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
await expect(host.reload([provider("B", true)])).rejects.toBe(failure);
expect(trace).toEqual(["activate A", "activate B", "deactivate B"]);
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
await host.reload([]);
await host.dispose();
expect(trace).toEqual(["activate A", "activate B", "deactivate B", "deactivate consumer", "deactivate A"]);
});
test("creates a reusable static loader", async () => {
const loader = createStaticFacetLoader([firstFacet]);
const first = await loader.load();
const second = await loader.load();
expect(first.facets).toEqual([firstFacet]);
expect(second.facets).toEqual([firstFacet]);
await Promise.all([first.dispose(), second.dispose()]);
});
});

View File

@@ -0,0 +1,673 @@
import { describe, expect, test, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
type Context,
createFacetHost,
createRemoteServiceBinding,
defineFacet,
defineService,
type MutableReplicatedState,
RemoteServiceProvider,
type RemoteServiceSource,
type ReplicatedState,
} from "../src/index.ts";
import { createLoopbackServiceTransport } from "./helpers.ts";
interface Source {
read(context: Context): Promise<string>;
}
interface Projection {
read(context: Context): Promise<string>;
}
interface KeyedValue {
read(context: Context): Promise<string>;
}
interface Watched {
readonly state: ReplicatedState<{ value: number }>;
}
interface HostValues {
readonly name: string;
readonly use: string;
}
interface LocalKeyedValue {
readonly metadata: Map<string, string>;
read(): string;
}
interface LeftValue {
read(context: Context): Promise<string>;
}
interface RightValue {
read(context: Context): Promise<string>;
}
interface CombinedValue {
read(context: Context): Promise<string>;
}
const Source = defineService<Source>("test.experimental.source");
const Projection = defineService<Projection>("test.experimental.projection");
const KeyedValue = defineService<KeyedValue>("test.experimental.keyed-value");
const Watched = defineService<Watched>("test.experimental.watched");
const HostValues = defineService<HostValues>("test.experimental.host-values", { local: true });
const LocalKeyedValue = defineService<LocalKeyedValue>("test.experimental.local-keyed-value", { local: true });
const LeftValue = defineService<LeftValue>("test.experimental.left-value");
const RightValue = defineService<RightValue>("test.experimental.right-value");
const CombinedValue = defineService<CombinedValue>("test.experimental.combined-value");
describe("facet host", () => {
test("discovers setup dependencies before connecting stable service handles", async () => {
const trace: string[] = [];
let sourceHandle: Source | undefined;
const projection = defineFacet({
id: "projection",
setup(env) {
trace.push("setup projection");
sourceHandle = env.use(Source);
expect(() => sourceHandle!.read(BACKGROUND_CONTEXT)).toThrow(
"Facet projection service handles cannot be used while setting_up",
);
env.provide(Projection, {
read: (context) => sourceHandle!.read(context),
});
env.onActivate(() => {
trace.push("activate projection");
});
env.onDeactivate(() => {
trace.push("dispose projection");
});
},
});
const source = defineFacet({
id: "source",
setup(env) {
trace.push("setup source");
env.provide(Source, {
async read() {
return "value";
},
});
env.onActivate(() => {
trace.push("activate source");
});
env.onDeactivate(() => {
trace.push("dispose source");
});
},
});
const host = await createFacetHost({ facets: [projection, source] });
expect(trace).toEqual(["setup projection", "setup source", "activate source", "activate projection"]);
expect(await sourceHandle!.read(BACKGROUND_CONTEXT)).toBe("value");
expect(await host.services.use(Projection).read(BACKGROUND_CONTEXT)).toBe("value");
await host.dispose();
expect(trace.slice(-2)).toEqual(["dispose projection", "dispose source"]);
});
test("connects keyed observations only when the observing facet activates", async () => {
const trace: string[] = [];
const observer = defineFacet({
id: "observer",
setup(env) {
env.observe(KeyedValue, async (service, context) => {
trace.push(`observe ${await service.read(context)}`);
});
env.onActivate(() => {
trace.push("activate observer");
});
},
});
const provider = defineFacet({
id: "provider",
setup(env) {
const values = env.provideMany(KeyedValue);
env.onActivate(() => {
trace.push("activate provider");
values.spawn("one", {
async read() {
return "one";
},
});
});
},
});
const host = await createFacetHost({ facets: [observer, provider] });
await vi.waitFor(() => expect(trace).toContain("observe one"));
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
const remoteServices = createRemoteServiceBinding({
services: [KeyedValue],
transport: createLoopbackServiceTransport(host.services),
});
const remoteValues: string[] = [];
remoteServices.observe(KeyedValue, async (service, context) => {
remoteValues.push(await service.read(context));
});
await remoteServices.ready(BACKGROUND_CONTEXT);
await vi.waitFor(() => expect(remoteValues).toEqual(["one"]));
await remoteServices.dispose(BACKGROUND_CONTEXT);
await host.dispose();
});
test("routes remotely exposable keyed services through the host provider", async () => {
const observed: Array<{ service: KeyedValue; context: Context }> = [];
const consumer = defineFacet({
id: "remote-keyed-consumer",
setup(env) {
env.observe(KeyedValue, (service, context) => {
observed.push({ service, context });
});
},
});
const provider = (value: string) =>
defineFacet({
id: "remote-keyed-provider",
setup(env) {
const values = env.provideMany(KeyedValue);
env.onActivate(() => {
values.spawn("current", {
async read() {
return value;
},
});
});
},
});
const host = await createFacetHost({ facets: [consumer, provider("A")] });
await vi.waitFor(() => expect(observed).toHaveLength(1));
const first = observed[0]!;
await expect(first.service.read(first.context)).resolves.toBe("A");
await host.reload([provider("B")]);
await vi.waitFor(() => expect(observed).toHaveLength(2));
expect(first.context.abortSignal?.aborted).toBe(true);
expect(() => first.service.read(first.context)).toThrow("observation is closed");
await expect(observed[1]!.service.read(observed[1]!.context)).resolves.toBe("B");
await host.dispose();
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
});
test("terminates the host when keyed replacement publication fails", async () => {
const failure = new Error("spawn publication failed");
const provider = (value: string) =>
defineFacet({
id: "failing-keyed-provider",
setup(env) {
const values = env.provideMany(KeyedValue);
env.onActivate(() => {
values.spawn("current", {
async read() {
return value;
},
});
});
},
});
const host = await createFacetHost({ facets: [provider("A")] });
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
if (update.type === "spawned") throw failure;
});
subscription.activate();
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
expect(() => host.services.use(KeyedValue)).toThrow("Remote service provider is disposed");
await host.dispose();
});
test("terminates the host when keyed retirement publication fails", async () => {
const failure = new Error("close publication failed");
const provider = (value: string) =>
defineFacet({
id: "failing-keyed-retirement-provider",
setup(env) {
const values = env.provideMany(KeyedValue);
env.onActivate(() => {
values.spawn("current", {
async read() {
return value;
},
});
});
},
});
const host = await createFacetHost({ facets: [provider("A")] });
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
if (update.type === "closed") throw failure;
});
subscription.activate();
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
await host.dispose();
});
test("keeps unrestricted local keyed services process-local across provider reloads", async () => {
const observed: Array<{ service: LocalKeyedValue; context: Context }> = [];
const consumer = defineFacet({
id: "local-keyed-consumer",
setup(env) {
env.observe(LocalKeyedValue, (service, context) => {
observed.push({ service, context });
});
},
});
const provider = (value: string) =>
defineFacet({
id: "local-keyed-provider",
setup(env) {
const values = env.provideMany(LocalKeyedValue);
env.onActivate(() => {
values.spawn("current", {
metadata: new Map([["value", value]]),
read: () => value,
});
});
},
});
const host = await createFacetHost({ facets: [consumer, provider("A")] });
await vi.waitFor(() => expect(observed).toHaveLength(1));
const first = observed[0]!;
expect(first.service.read()).toBe("A");
expect(first.service.metadata.get("value")).toBe("A");
expect(host.services.catalogue).not.toContainEqual({ serviceId: LocalKeyedValue.id, mode: "keyed" });
expect(() => host.services.use(LocalKeyedValue)).toThrow("process-local");
await host.reload([provider("B")]);
await vi.waitFor(() => expect(observed).toHaveLength(2));
expect(first.context.abortSignal?.aborted).toBe(true);
expect(() => first.service.read()).toThrow(`Keyed service ${LocalKeyedValue.id} observation is closed`);
expect(observed[1]!.service.read()).toBe("B");
expect(observed[1]!.service.metadata.get("value")).toBe("B");
await host.dispose();
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
});
test("keeps remotely exposable local state replicas stable across provider reloads", async () => {
const sources: MutableReplicatedState<{ value: number }>[] = [];
const revisions: number[] = [];
let watched: Watched | undefined;
const consumer = defineFacet({
id: "state-consumer",
setup(env) {
watched = env.use(Watched);
env.onActivate(() => {
env.own(watched!.state.subscribe(({ value }) => revisions.push(value)));
});
},
});
const provider = (value: number) =>
defineFacet({
id: "state-provider",
setup(env) {
const state = env.replicatedState({ value });
sources.push(state);
env.provide(Watched, { state });
},
});
const host = await createFacetHost({ facets: [consumer, provider(1)] });
const retainedState = watched!.state;
expect(retainedState.value).toEqual({ value: 1 });
expect(revisions).toEqual([1]);
await host.reload([provider(2)]);
expect(watched!.state).toBe(retainedState);
expect(retainedState.value).toEqual({ value: 2 });
expect(revisions).toEqual([1, 2]);
sources[0]!.state.value = 3;
sources[0]!.publish(BACKGROUND_CONTEXT);
expect(retainedState.value).toEqual({ value: 2 });
expect(revisions).toEqual([1, 2]);
await host.dispose();
expect(() => retainedState.value).toThrow("Facet state-consumer service handles cannot be used while dead");
});
test("scopes singleton service views to each facet lifecycle", async () => {
const consumerHandles: Source[] = [];
const cleanupValues: string[] = [];
let peerHandle: Source | undefined;
const consumer = (generation: string) =>
defineFacet({
id: "scoped-consumer",
setup(env) {
const source = env.use(Source);
expect(env.use(Source)).toBe(source);
consumerHandles.push(source);
expect(() => source.read(BACKGROUND_CONTEXT)).toThrow(
"Facet scoped-consumer service handles cannot be used while setting_up",
);
env.onDeactivate(async () => {
cleanupValues.push(`${generation}:${await source.read(BACKGROUND_CONTEXT)}`);
});
},
});
const peer = defineFacet({
id: "peer-consumer",
setup(env) {
peerHandle = env.use(Source);
},
});
const provider = defineFacet({
id: "scoped-provider",
setup(env) {
env.provide(Source, {
async read() {
return "value";
},
});
},
});
const host = await createFacetHost({ facets: [consumer("A"), peer, provider] });
expect(consumerHandles[0]).not.toBe(peerHandle);
const retainedOldRead = consumerHandles[0]!.read;
await host.reload([consumer("B")]);
expect(cleanupValues).toEqual(["A:value"]);
expect(Object.is(consumerHandles[1], consumerHandles[0])).toBe(false);
expect(() => consumerHandles[0]!.read(BACKGROUND_CONTEXT)).toThrow(
"Facet scoped-consumer service handles cannot be used while dead",
);
expect(() => retainedOldRead(BACKGROUND_CONTEXT)).toThrow(
"Facet scoped-consumer service handles cannot be used while dead",
);
await expect(consumerHandles[1]!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
await expect(peerHandle!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
await host.dispose();
expect(cleanupValues).toEqual(["A:value", "B:value"]);
expect(() => consumerHandles[1]!.read(BACKGROUND_CONTEXT)).toThrow(
"Facet scoped-consumer service handles cannot be used while dead",
);
expect(() => peerHandle!.read(BACKGROUND_CONTEXT)).toThrow(
"Facet peer-consumer service handles cannot be used while dead",
);
});
test("owns resources registered during activation", async () => {
let state: MutableReplicatedState<{ value: number }> | undefined;
let deliveries = 0;
const consumer = defineFacet({
id: "consumer",
setup(env) {
const watched = env.use(Watched);
env.onActivate(() => {
env.own(
watched.state.subscribe(() => {
deliveries += 1;
}),
);
});
},
});
const provider = defineFacet({
id: "provider",
setup(env) {
state = env.replicatedState({ value: 0 });
env.provide(Watched, { state });
},
});
const host = await createFacetHost({ facets: [consumer, provider] });
expect(deliveries).toBe(1);
state!.state.value = 1;
state!.publish(BACKGROUND_CONTEXT);
expect(deliveries).toBe(2);
await host.dispose();
state!.state.value = 2;
state!.publish(BACKGROUND_CONTEXT);
expect(deliveries).toBe(2);
});
test("provides arbitrary host services through the facet graph", async () => {
const values: HostValues = { name: "session", use: "host value" };
const consumer = defineFacet({
id: "host-service-consumer",
setup(env) {
const hostValues = env.use(HostValues);
expect(() => hostValues.use).toThrow(
"Facet host-service-consumer service handles cannot be used while setting_up",
);
env.onActivate(() => {
expect(hostValues).not.toBe(values);
expect(hostValues.name).toBe("session");
expect(hostValues.use).toBe("host value");
});
},
});
const provider = defineFacet({
id: "host-service-provider",
setup(env) {
env.provide(HostValues, values);
},
});
const host = await createFacetHost({ facets: [consumer, provider] });
expect(() => host.services.use(HostValues)).toThrow("Service test.experimental.host-values is process-local");
await host.dispose();
});
test("combines connected services and facet-provided services in one host", async () => {
const leftProvider = new RemoteServiceProvider([LeftValue]);
leftProvider.provide(LeftValue, {
async read() {
return "left";
},
});
const rightProvider = new RemoteServiceProvider([RightValue]);
rightProvider.provide(RightValue, {
async read() {
return "right";
},
});
const leftNamespace = createRemoteServiceBinding({
services: [LeftValue],
transport: createLoopbackServiceTransport(leftProvider),
bound: false,
});
const rightNamespace = createRemoteServiceBinding({
services: [RightValue],
transport: createLoopbackServiceTransport(rightProvider),
bound: false,
});
const serviceSources = [
{ namespace: leftNamespace, provider: leftProvider },
{ namespace: rightNamespace, provider: rightProvider },
].map(({ namespace, provider }) => {
const ready = namespace.ready.bind(namespace);
return Object.assign(namespace, {
acceptsUnavailableServices: false,
async catalogue() {
return provider.catalogue;
},
open() {
return namespace;
},
async ready(context: Context) {
await namespace.rebind(true, context);
await ready(context);
},
});
});
const facet = defineFacet({
id: "combined",
setup(env) {
const left = env.use(LeftValue);
const right = env.use(RightValue);
env.provide(CombinedValue, {
async read(context) {
return `${await left.read(context)} ${await right.read(context)}`;
},
});
},
});
const host = await createFacetHost({ facets: [facet], serviceSources });
await expect(host.services.use(CombinedValue).read(BACKGROUND_CONTEXT)).resolves.toBe("left right");
await host.dispose();
await Promise.all([leftNamespace.dispose(BACKGROUND_CONTEXT), rightNamespace.dispose(BACKGROUND_CONTEXT)]);
leftProvider.dispose();
rightProvider.dispose();
});
test("rejects a service offered by multiple sources", async () => {
const duplicate = {
acceptsUnavailableServices: false,
async catalogue() {
return [{ serviceId: LeftValue.id, mode: "singleton" as const }];
},
open() {
throw new Error("Ambiguous sources must not open");
},
} satisfies RemoteServiceSource;
const consumer = defineFacet({
id: "duplicate-consumer",
setup(env) {
env.use(LeftValue);
},
});
await expect(createFacetHost({ facets: [consumer], serviceSources: [duplicate, duplicate] })).rejects.toThrow(
`Facet host service ${LeftValue.id} is offered by more than one source`,
);
});
test("reopens source bindings from changed catalogues for a replacement generation", async () => {
const leftProvider = new RemoteServiceProvider([LeftValue]);
leftProvider.provide(LeftValue, {
async read() {
return "left";
},
});
const rightProvider = new RemoteServiceProvider([RightValue]);
rightProvider.provide(RightValue, {
async read() {
return "right";
},
});
let currentProvider = leftProvider;
let opened = 0;
let disposed = 0;
const source = {
acceptsUnavailableServices: false,
async catalogue() {
return currentProvider.catalogue;
},
open(options) {
opened += 1;
const namespace = createRemoteServiceBinding({
services: options.services,
transport: createLoopbackServiceTransport(currentProvider),
bound: false,
assertAccess: options.assertAccess,
onError: options.onError,
});
const ready = namespace.ready.bind(namespace);
const dispose = namespace.dispose.bind(namespace);
return Object.assign(namespace, {
async ready(context: Context) {
await namespace.rebind(true, context);
await ready(context);
},
async dispose(context: Context) {
disposed += 1;
await dispose(context);
},
});
},
} satisfies RemoteServiceSource;
const values: string[] = [];
const leftConsumer = defineFacet({
id: "left-consumer",
setup(env) {
const left = env.use(LeftValue);
env.onActivate(async () => {
values.push(await left.read(BACKGROUND_CONTEXT));
});
},
});
const first = await createFacetHost({ facets: [leftConsumer], serviceSources: [source] });
await first.dispose();
currentProvider = rightProvider;
const rightConsumer = defineFacet({
id: "right-consumer",
setup(env) {
const right = env.use(RightValue);
env.onActivate(async () => {
values.push(await right.read(BACKGROUND_CONTEXT));
});
},
});
const second = await createFacetHost({ facets: [rightConsumer], serviceSources: [source] });
await second.dispose();
expect(values).toEqual(["left", "right"]);
expect(opened).toBe(2);
expect(disposed).toBe(2);
leftProvider.dispose();
rightProvider.dispose();
});
test("rejects missing dependencies, cycles, and asynchronous setup", async () => {
let activated = false;
const missing = defineFacet({
id: "missing",
setup(env) {
env.use(Source);
env.onActivate(() => {
activated = true;
});
},
});
await expect(createFacetHost({ facets: [missing] })).rejects.toThrow(
"Facet missing requires local/test.experimental.source/singleton, but no facet provides it",
);
expect(activated).toBe(false);
const first = defineFacet({
id: "first",
setup(env) {
env.use(Projection);
env.provide(Source, {
async read() {
return "first";
},
});
},
});
const second = defineFacet({
id: "second",
setup(env) {
env.use(Source);
env.provide(Projection, {
async read() {
return "second";
},
});
},
});
await expect(createFacetHost({ facets: [first, second] })).rejects.toThrow(
"Facet dependency cycle: first, second",
);
const asynchronous = defineFacet({
id: "asynchronous",
async setup() {
await Promise.resolve();
},
});
await expect(createFacetHost({ facets: [asynchronous] })).rejects.toThrow(
"Facet asynchronous setup must be synchronous",
);
});
});

View File

@@ -0,0 +1 @@
export { createLoopbackServiceTransport } from "../src/services/loopback.ts";

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { isJsonValue } from "../src/index.ts";
describe("isJsonValue", () => {
test("checks strict JSON without normalizing it", () => {
expect(isJsonValue({ nested: [1, true, null] })).toBe(true);
expect(isJsonValue({ omitted: undefined })).toBe(false);
expect(isJsonValue(new Uint8Array([1]))).toBe(false);
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
const cyclic: { self?: unknown } = {};
cyclic.self = cyclic;
expect(isJsonValue(cyclic)).toBe(false);
});
});

View File

@@ -0,0 +1,269 @@
import { describe, expect, test } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
createRemoteServiceEndpoint,
createServiceCatalogueCall,
createServiceStateDecoder,
createServiceStateEncoder,
createServiceSubscribeCall,
createServiceUnsubscribeCall,
decodeServiceControlCall,
defineService,
parseServiceCall,
parseServiceCatalogue,
parseServiceProviderUpdate,
parseServiceSubscriptionSnapshot,
parseWireServiceProviderUpdate,
parseWireServiceSubscriptionSnapshot,
RemoteServiceProvider,
type ReplicatedState,
replicatedState,
type ServiceProviderUpdate,
type ServiceSubscriptionSnapshot,
} from "../src/index.ts";
describe("service wire protocol", () => {
test("encodes control calls and validates service values", () => {
expect(decodeServiceControlCall(createServiceCatalogueCall())).toEqual({ type: "catalogue" });
expect(
parseServiceCatalogue([
{ serviceId: "pi.models", mode: "singleton" },
{ serviceId: "pi.dialogs", mode: "keyed" },
]),
).toEqual([
{ serviceId: "pi.models", mode: "singleton" },
{ serviceId: "pi.dialogs", mode: "keyed" },
]);
const subscribe = createServiceSubscribeCall("subscription-1", "pi.models", "singleton");
expect(decodeServiceControlCall(subscribe)).toEqual({
type: "subscribe",
subscriptionId: "subscription-1",
serviceId: "pi.models",
mode: "singleton",
});
expect(decodeServiceControlCall(createServiceUnsubscribeCall("subscription-1"))).toEqual({
type: "unsubscribe",
subscriptionId: "subscription-1",
});
expect(
parseServiceCall({
serviceId: "pi.question-dialog",
instance: { key: "invocation-1", generation: 2 },
member: "submit",
args: [{ outcome: "selected", index: 0 }],
}),
).toMatchObject({ member: "submit" });
});
test("rejects malformed service values", () => {
expect(() => parseServiceCall({ serviceId: "pi.models", member: "list", args: [], extra: true })).toThrow(
"Invalid service call",
);
expect(() => parseServiceCatalogue([{ serviceId: "pi.models", mode: "unknown" }])).toThrow(
"Invalid service catalogue",
);
expect(() => parseServiceProviderUpdate({ type: "state", member: "state", sequence: 0, ops: [] })).toThrow(
"Invalid service state update",
);
expect(() =>
parseWireServiceProviderUpdate({ type: "state", member: "state", sequence: 1, ops: [["?", 0]] }),
).toThrow();
});
test("validates decoded and wire snapshots and updates", () => {
const snapshot: ServiceSubscriptionSnapshot = {
serviceId: "pi.models",
mode: "singleton",
instances: [
{
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 1 }]] }],
},
],
};
expect(parseServiceSubscriptionSnapshot(snapshot)).toBe(snapshot);
const encoder = createServiceStateEncoder();
const wireSnapshot = encoder.encodeSnapshot(snapshot);
expect(parseWireServiceSubscriptionSnapshot(wireSnapshot)).toBe(wireSnapshot);
const update: ServiceProviderUpdate = {
type: "state",
member: "state",
sequence: 1,
ops: [["s", ["revision"], 2]],
};
expect(parseServiceProviderUpdate(update)).toBe(update);
const wireUpdate = encoder.encodeUpdate(update);
expect(parseWireServiceProviderUpdate(wireUpdate)).toBe(wireUpdate);
});
test("keeps one operation codec pair for one subscription state", () => {
const enc = createServiceStateEncoder();
const dec = createServiceStateDecoder();
const snapshot: ServiceSubscriptionSnapshot = {
serviceId: "pi.models",
mode: "singleton",
instances: [
{
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }],
},
],
};
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
const first: ServiceProviderUpdate = {
type: "state",
member: "state",
sequence: 1,
ops: [["s", ["revision"], 1]],
};
const second: ServiceProviderUpdate = {
type: "state",
member: "state",
sequence: 2,
ops: [["s", ["revision"], 2]],
};
const firstWire = enc.encodeUpdate(first);
const secondWire = enc.encodeUpdate(second);
expect(firstWire).toMatchObject({ ops: [["s", ["revision"], 1]] });
expect(secondWire).toMatchObject({
ops: [
["#", 0, ["revision"]],
["s", 0, 2],
],
});
expect(dec.decodeUpdate(firstWire)).toEqual(first);
expect(dec.decodeUpdate(secondWire)).toEqual(second);
});
test("isolates operation dictionaries between states and subscriptions", () => {
const snapshot: ServiceSubscriptionSnapshot = {
serviceId: "pi.states",
mode: "singleton",
instances: [
{
members: [
{ name: "left", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
{ name: "right", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
],
},
],
};
const firstEncoder = createServiceStateEncoder();
const firstDecoder = createServiceStateDecoder();
const secondEncoder = createServiceStateEncoder();
const secondDecoder = createServiceStateDecoder();
firstDecoder.decodeSnapshot(firstEncoder.encodeSnapshot(snapshot));
secondDecoder.decodeSnapshot(secondEncoder.encodeSnapshot(snapshot));
const update = (member: string, sequence: number, revision: number): ServiceProviderUpdate => ({
type: "state",
member,
sequence,
ops: [["s", ["revision"], revision]],
});
const firstLeft = firstEncoder.encodeUpdate(update("left", 1, 1));
const firstRight = firstEncoder.encodeUpdate(update("right", 1, 1));
const secondLeft = firstEncoder.encodeUpdate(update("left", 2, 2));
const secondRight = firstEncoder.encodeUpdate(update("right", 2, 2));
expect(firstLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
expect(firstRight).toMatchObject({ ops: [["s", ["revision"], 1]] });
expect(secondLeft).toMatchObject({
ops: [
["#", 0, ["revision"]],
["s", 0, 2],
],
});
expect(secondRight).toMatchObject({
ops: [
["#", 0, ["revision"]],
["s", 0, 2],
],
});
expect(firstDecoder.decodeUpdate(firstLeft)).toEqual(update("left", 1, 1));
expect(firstDecoder.decodeUpdate(firstRight)).toEqual(update("right", 1, 1));
expect(firstDecoder.decodeUpdate(secondLeft)).toEqual(update("left", 2, 2));
expect(firstDecoder.decodeUpdate(secondRight)).toEqual(update("right", 2, 2));
const independentLeft = secondEncoder.encodeUpdate(update("left", 1, 1));
expect(independentLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
expect(secondDecoder.decodeUpdate(independentLeft)).toEqual(update("left", 1, 1));
const leftBase: ServiceProviderUpdate = {
type: "state",
member: "left",
sequence: 3,
ops: [["r", { revision: 3 }]],
};
expect(firstDecoder.decodeUpdate(firstEncoder.encodeUpdate(leftBase))).toEqual(leftBase);
const thirdRight = firstEncoder.encodeUpdate(update("right", 3, 3));
expect(thirdRight).toMatchObject({ ops: [["s", 0, 3]] });
expect(firstDecoder.decodeUpdate(thirdRight)).toEqual(update("right", 3, 3));
});
test("creates and removes keyed instance codecs with their lifecycle", () => {
const enc = createServiceStateEncoder();
const dec = createServiceStateDecoder();
const snapshot: ServiceSubscriptionSnapshot = {
serviceId: "pi.dialogs",
mode: "keyed",
instances: [],
};
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
const address = { key: "dialog-1", generation: 1 };
const spawned: ServiceProviderUpdate = {
type: "spawned",
instance: {
instance: address,
members: [{ name: "request", kind: "state", sequence: 0, ops: [["r", { value: 0 }]] }],
},
};
expect(dec.decodeUpdate(enc.encodeUpdate(spawned))).toEqual(spawned);
const update: ServiceProviderUpdate = {
type: "state",
instance: address,
member: "request",
sequence: 1,
ops: [["s", ["value"], 1]],
};
expect(dec.decodeUpdate(enc.encodeUpdate(update))).toEqual(update);
const closed: ServiceProviderUpdate = { type: "closed", instance: address };
expect(dec.decodeUpdate(enc.encodeUpdate(closed))).toEqual(closed);
expect(() => enc.encodeUpdate({ ...update, sequence: 2 })).toThrow("Unknown service state");
});
});
interface Counter {
readonly state: ReplicatedState<{ value: number }>;
}
const Counter = defineService<Counter>("test.counter");
test("remote service endpoints publish and clean up provider subscriptions", async () => {
const provider = new RemoteServiceProvider([Counter]);
const state = replicatedState({ value: 0 });
provider.provide(Counter, { state });
const endpoint = createRemoteServiceEndpoint(provider);
const updates: ServiceProviderUpdate[] = [];
const publish = (_subscriptionId: string, update: ServiceProviderUpdate): void => {
updates.push(update);
};
await expect(endpoint.invoke(createServiceCatalogueCall(), publish, BACKGROUND_CONTEXT)).resolves.toEqual([
{ serviceId: Counter.id, mode: "singleton" },
]);
await expect(
endpoint.invoke(
createServiceSubscribeCall("subscription-1", Counter.id, "singleton"),
publish,
BACKGROUND_CONTEXT,
),
).resolves.toMatchObject({ serviceId: Counter.id, mode: "singleton" });
state.state.value = 1;
state.publish(BACKGROUND_CONTEXT);
expect(updates).toEqual([{ type: "state", member: "state", sequence: 1, ops: [["s", ["value"], 1]] }]);
endpoint.dispose();
state.state.value = 2;
state.publish(BACKGROUND_CONTEXT);
expect(updates).toHaveLength(1);
provider.dispose();
});

View File

@@ -0,0 +1,679 @@
import { describe, expect, test, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
import {
type Context,
createRemoteServiceBinding,
defineService,
type JsonValue,
RemoteServiceProvider,
type RemoteServiceTransport,
type ReplicatedState,
replicatedState,
} from "../src/index.ts";
import { createLoopbackServiceTransport } from "./helpers.ts";
type ModelRef = { provider: string; modelId: string };
type ModelsState = {
selected: ModelRef | null;
revision: number;
};
interface Models {
readonly state: ReplicatedState<ModelsState>;
select(model: ModelRef, context: Context): Promise<void>;
}
const Models = defineService<Models>("test.models");
type Question = { question: string };
interface QuestionDialogs {
readonly request: ReplicatedState<Question>;
submit(answer: string, context: Context): Promise<{ accepted: boolean }>;
}
const QuestionDialogs = defineService<QuestionDialogs>("test.question-dialog");
type EchoPayload = { value: string };
interface Echo {
echo(payload: EchoPayload, context: Context): Promise<EchoPayload>;
}
const Echo = defineService<Echo>("test.echo");
interface Timeline {
readonly state: ReplicatedState<{ entries: { id: string }[]; retained: { value: number } }>;
}
const Timeline = defineService<Timeline>("test.timeline");
interface JsonPassthrough {
call(value: JsonValue, context: Context): Promise<JsonValue>;
}
interface NonJsonArgument {
call(value: Date, context: Context): Promise<void>;
}
interface NonJsonResult {
call(context: Context): Promise<bigint>;
}
interface NonJsonState {
readonly state: ReplicatedState<{ value: undefined }>;
}
describe("remote services", () => {
test("checks remote JSON contracts only at compile time", () => {
expect(defineService<JsonPassthrough>("test.json-passthrough").local).toBe(false);
const defineInvalidContracts = (): void => {
// @ts-expect-error Remote service arguments must be JSON-compatible.
defineService<NonJsonArgument>("test.non-json-argument");
// @ts-expect-error Remote service results must be JSON-compatible.
defineService<NonJsonResult>("test.non-json-result");
// @ts-expect-error Replicated state must be JSON-compatible.
defineService<NonJsonState>("test.non-json-state");
};
expect(defineInvalidContracts).not.toThrow();
expect(defineService<NonJsonArgument>("test.local-non-json", { local: true }).local).toBe(true);
});
test("marks services remotable by default and reserves Chord service IDs", () => {
const local = defineService<{ readonly value: string }>("test.local", { local: true });
expect(Models.local).toBe(false);
expect(local.local).toBe(true);
expect(() => defineService("$chord.internal", { local: true })).toThrow(
"Service IDs beginning with $chord. are reserved",
);
expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely");
});
test("tracks mutable source state while publishing immutable revisions", () => {
const initial: ModelsState = { selected: null, revision: 0 };
const state = replicatedState(initial);
let delivered: ModelsState | undefined;
const deliveries: string[] = [];
const unsubscribe = state.subscribe((value, _context, delivery) => {
delivered = value;
deliveries.push(delivery.kind);
});
expect(state.value).toEqual(initial);
expect(state.value).not.toBe(initial);
expect(delivered).toBe(state.value);
const hydrated = delivered;
state.state.selected = { provider: "test", modelId: "one" };
state.state.revision = 1;
state.publish(BACKGROUND_CONTEXT);
expect(state.value).toEqual({ selected: { provider: "test", modelId: "one" }, revision: 1 });
expect(state.value).not.toBe(initial);
expect(delivered).toBe(state.value);
expect(hydrated).toEqual({ selected: null, revision: 0 });
expect(deliveries).toEqual(["hydrate", "update"]);
unsubscribe();
});
test("flushes pending mutations before hydrating a new state subscriber", () => {
const state = replicatedState({ entries: [{ id: "one" }] });
const first: { entries: { id: string }[] }[] = [];
state.subscribe((value) => first.push(value));
state.state.entries.push({ id: "two" });
const second: { entries: { id: string }[] }[] = [];
state.subscribe((value) => second.push(value));
expect(first).toEqual([{ entries: [{ id: "one" }] }, { entries: [{ id: "one" }, { id: "two" }] }]);
expect(second).toEqual([{ entries: [{ id: "one" }, { id: "two" }] }]);
});
test("does not defensively clone method arguments or results", async () => {
const provider = new RemoteServiceProvider([Echo]);
let received: EchoPayload | undefined;
const response: EchoPayload = { value: "response" };
provider.provide(Echo, {
async echo(payload) {
received = payload;
return response;
},
});
const namespace = createRemoteServiceBinding({
services: [Echo],
transport: createLoopbackServiceTransport(provider),
});
const echo = namespace.use(Echo);
await namespace.ready(BACKGROUND_CONTEXT);
const request: EchoPayload = { value: "request" };
await expect(echo.echo(request, BACKGROUND_CONTEXT)).resolves.toBe(response);
expect(received).toBe(request);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("provides and consumes one singleton with replicated state", async () => {
const provider = new RemoteServiceProvider([Models]);
expect(provider.catalogue).toEqual([{ serviceId: Models.id, mode: "singleton" }]);
const initialState: ModelsState = { selected: null, revision: 0 };
const state = replicatedState(initialState);
let publishedState: ModelsState | undefined;
provider.provide(Models, {
state,
async select(model, context) {
state.state.selected = model;
state.state.revision += 1;
state.publish(context);
publishedState = state.value;
},
});
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [Models],
transport: createLoopbackServiceTransport(provider),
onError: (error) => errors.push(error),
});
const first = namespace.use(Models);
const second = namespace.use(Models);
expect(first).toBe(second);
expect(first.state.value).toBeUndefined();
await namespace.ready(BACKGROUND_CONTEXT);
expect(first.state.value).toEqual(initialState);
const updates: ModelsState[] = [];
const unsubscribe = second.state.subscribe((value) => updates.push(value));
await first.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT);
expect(first.state.value).toEqual(publishedState);
expect(first.state.value).toEqual({
selected: { provider: "test", modelId: "one" },
revision: 1,
});
expect(updates).toEqual([
{ selected: null, revision: 0 },
{ selected: { provider: "test", modelId: "one" }, revision: 1 },
]);
expect(errors).toEqual([]);
const lateNamespace = createRemoteServiceBinding({
services: [Models],
transport: createLoopbackServiceTransport(provider),
});
const lateModels = lateNamespace.use(Models);
await lateNamespace.ready(BACKGROUND_CONTEXT);
expect(lateModels.state.value?.revision).toBe(1);
unsubscribe();
await Promise.all([namespace.dispose(BACKGROUND_CONTEXT), lateNamespace.dispose(BACKGROUND_CONTEXT)]);
provider.dispose();
});
test("publishes compact tracked operations through the remote provider", async () => {
const provider = new RemoteServiceProvider([Timeline]);
const initial = { entries: [{ id: "one" }], retained: { value: 1 } };
const source = replicatedState(initial);
provider.provide(Timeline, { state: source });
const updates: Array<Parameters<Parameters<typeof provider.subscribe>[2]>[0]> = [];
const raw = provider.subscribe(Timeline.id, "singleton", (update) => updates.push(update));
expect(raw.snapshot.instances[0]?.members).toEqual([
{ name: "state", kind: "state", sequence: 0, ops: [["r", initial]] },
]);
raw.activate();
const namespace = createRemoteServiceBinding({
services: [Timeline],
transport: createLoopbackServiceTransport(provider),
});
const timeline = namespace.use(Timeline);
await namespace.ready(BACKGROUND_CONTEXT);
const previous = timeline.state.value;
const next = { entries: [{ id: "one" }, { id: "two" }], retained: initial.retained };
source.state.entries.push({ id: "two" });
source.publish(BACKGROUND_CONTEXT);
expect(updates).toContainEqual({
type: "state",
member: "state",
sequence: 1,
ops: [["p", ["entries"], 1, 0, [{ id: "two" }]]],
});
expect(previous).toEqual({ entries: [{ id: "one" }], retained: { value: 1 } });
expect(timeline.state.value).toEqual(next);
source.state.entries.push({ id: "three" });
const late = provider.subscribe(Timeline.id, "singleton", () => {});
expect(updates.at(-1)).toEqual({
type: "state",
member: "state",
sequence: 2,
ops: [["p", ["entries"], 2, 0, [{ id: "three" }]]],
});
expect(late.snapshot.instances[0]?.members).toEqual([
{
name: "state",
kind: "state",
sequence: 2,
ops: [["r", { entries: [{ id: "one" }, { id: "two" }, { id: "three" }], retained: { value: 1 } }]],
},
]);
late.close();
raw.close();
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("keeps singleton facades stable when their provider is replaced", async () => {
const provider = new RemoteServiceProvider([Models]);
provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
async select() {},
});
const namespace = createRemoteServiceBinding({
services: [Models],
transport: createLoopbackServiceTransport(provider),
});
const models = namespace.use(Models);
const state = models.state;
const select = models.select;
await namespace.ready(BACKGROUND_CONTEXT);
expect(state.value?.revision).toBe(1);
provider.withdraw(Models);
expect(state.value).toBeUndefined();
await expect(select({ provider: "test", modelId: "unavailable" }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
code: "service_not_found",
});
const replacementSelect = vi.fn(async () => {});
provider.replace(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
select: replacementSelect,
});
expect(namespace.use(Models)).toBe(models);
expect(models.state).toBe(state);
expect(state.value?.revision).toBe(2);
await select({ provider: "test", modelId: "replacement" }, BACKGROUND_CONTEXT);
expect(replacementSelect).toHaveBeenCalledOnce();
expect(() =>
provider.replace(Models, {
async select() {},
} as unknown as Models),
).toThrow("replacement must preserve its member shape");
expect(() =>
provider.replace(Models, {
async state() {},
async select() {},
} as unknown as Models),
).toThrow("replacement must preserve its member shape");
expect(state.value?.revision).toBe(2);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("delivers active subscriber updates before reporting listener failures", () => {
const failure = new Error("listener failed");
const provider = new RemoteServiceProvider([Models]);
provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
async select() {},
});
let delivered = 0;
const failing = provider.subscribe(Models.id, "singleton", () => {
throw failure;
});
const succeeding = provider.subscribe(Models.id, "singleton", () => {
delivered += 1;
});
failing.activate();
succeeding.activate();
expect(() =>
provider.replace(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
async select() {},
}),
).toThrow(failure);
expect(delivered).toBe(1);
failing.close();
succeeding.close();
provider.dispose();
});
test("replays every buffered update before reporting listener failures", () => {
const failure = new Error("listener failed");
const provider = new RemoteServiceProvider([Models]);
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
provider.provide(Models, { state, async select() {} });
let delivered = 0;
const subscription = provider.subscribe(Models.id, "singleton", () => {
delivered += 1;
throw failure;
});
state.state.revision = 1;
state.publish(BACKGROUND_CONTEXT);
state.state.revision = 2;
state.publish(BACKGROUND_CONTEXT);
expect(() => subscription.activate()).toThrow("Failed to activate remote service subscription");
expect(delivered).toBe(2);
subscription.close();
provider.dispose();
});
test("clears retained facades when providers and bindings are disposed", async () => {
const provider = new RemoteServiceProvider([Models]);
provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
async select() {},
});
const namespace = createRemoteServiceBinding({
services: [Models],
transport: createLoopbackServiceTransport(provider),
});
const models = namespace.use(Models);
const state = models.state;
await namespace.ready(BACKGROUND_CONTEXT);
expect(state.value?.revision).toBe(1);
provider.dispose();
expect(state.value).toBeUndefined();
await expect(models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).rejects.toThrow(
"Remote service provider is disposed",
);
await namespace.dispose(BACKGROUND_CONTEXT);
expect(() => state.value).toThrow("Remote service binding is disposed");
});
test("applies provider disposal buffered while subscriptions are starting", async () => {
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
async select() {},
});
provider.spawn(QuestionDialogs, "pending", {
request: replicatedState<Question>({ question: "Pending?" }),
async submit() {
return { accepted: true };
},
});
const namespace = createRemoteServiceBinding({
services: [Models, QuestionDialogs],
transport: createLoopbackServiceTransport(provider),
});
const models = namespace.use(Models);
const observed: QuestionDialogs[] = [];
namespace.observe(QuestionDialogs, (service) => {
observed.push(service);
});
provider.dispose();
await namespace.ready(BACKGROUND_CONTEXT);
expect(models.state.value).toBeUndefined();
expect(observed).toEqual([]);
await namespace.dispose(BACKGROUND_CONTEXT);
});
test("keeps deferred service handles inaccessible until host activation", async () => {
const provider = new RemoteServiceProvider([Models]);
provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
async select() {},
});
let active = false;
const namespace = createRemoteServiceBinding({
services: [Models],
transport: createLoopbackServiceTransport(provider),
bound: false,
assertAccess() {
if (!active) throw new Error("Service handles are not active");
},
});
const models = namespace.use(Models);
expect(() => models.state.value).toThrow("Service handles are not active");
expect(() => models.state.subscribe(() => {})).toThrow("Service handles are not active");
expect(() => models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).toThrow(
"Service handles are not active",
);
await namespace.rebind(true, BACKGROUND_CONTEXT);
active = true;
await namespace.ready(BACKGROUND_CONTEXT);
expect(models.state.value?.revision).toBe(0);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("rejects namespace readiness when initial hydration fails", async () => {
const failure = new Error("initial hydration failed");
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [Models],
transport: {
invoke: () => Promise.reject(new Error("unexpected invocation")),
subscribe: () => Promise.reject(failure),
},
onError: (error) => errors.push(error),
});
const models = namespace.use(Models);
await expect(namespace.ready(BACKGROUND_CONTEXT)).rejects.toBe(failure);
expect(models.state.value).toBeUndefined();
expect(errors).toEqual([failure]);
await namespace.dispose(BACKGROUND_CONTEXT);
});
test("buffers state updates that race subscription hydration", async () => {
const provider = new RemoteServiceProvider([Models]);
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
provider.provide(Models, {
state,
async select() {},
});
const transport: RemoteServiceTransport = {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, listener);
state.state.revision = 1;
state.publish(BACKGROUND_CONTEXT);
return {
snapshot: subscription.snapshot,
activate: () => subscription.activate(),
close: () => subscription.close(),
};
},
};
const namespace = createRemoteServiceBinding({ services: [Models], transport });
const models = namespace.use(Models);
const revisions: number[] = [];
models.state.subscribe((value) => revisions.push(value.revision));
await vi.waitFor(() => expect(revisions).toEqual([0, 1]));
expect(models.state.value?.revision).toBe(1);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test.each([
["duplicate", 0],
["gap", 2],
] as const)("clears replicated state after a %s operation sequence", async (_kind, sequence) => {
let sendUpdate: ((update: Parameters<Parameters<RemoteServiceTransport["subscribe"]>[2]>[0]) => void) | undefined;
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [Models],
transport: {
invoke: () => Promise.reject(new Error("unexpected invocation")),
async subscribe(_serviceId, _mode, listener) {
sendUpdate = (update) => listener(update, BACKGROUND_CONTEXT);
return {
snapshot: {
serviceId: Models.id,
mode: "singleton",
instances: [
{
members: [
{ name: "select", kind: "method" },
{
name: "state",
kind: "state",
sequence: 0,
ops: [["r", { selected: null, revision: 0 }]],
},
],
},
],
},
activate() {},
close() {},
};
},
},
onError: (error) => errors.push(error),
});
const models = namespace.use(Models);
await namespace.ready(BACKGROUND_CONTEXT);
expect(models.state.value?.revision).toBe(0);
sendUpdate?.({
type: "state",
member: "state",
sequence,
ops: [["r", { selected: null, revision: sequence }]],
});
expect(models.state.value).toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]?.message).toContain("sequence has a gap");
await namespace.dispose(BACKGROUND_CONTEXT);
});
test("hydrates cold ReplicatedState replicas and replaces them across rebinds", async () => {
const provider = new RemoteServiceProvider([Models]);
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
provider.provide(Models, {
state,
async select() {},
});
const namespace = createRemoteServiceBinding({
services: [Models],
transport: createLoopbackServiceTransport(provider),
bound: false,
});
const models = namespace.use(Models);
const revisions: number[] = [];
models.state.subscribe((value) => revisions.push(value.revision));
expect(models.state.value).toBeUndefined();
expect(revisions).toEqual([]);
state.state.revision = 1;
state.publish(BACKGROUND_CONTEXT);
await namespace.rebind(true, BACKGROUND_CONTEXT);
expect(models.state.value?.revision).toBe(1);
expect(revisions).toEqual([1]);
state.state.revision = 2;
state.publish(BACKGROUND_CONTEXT);
expect(revisions).toEqual([1, 2]);
await namespace.rebind(false, BACKGROUND_CONTEXT);
expect(models.state.value).toBeUndefined();
state.state.revision = 3;
state.publish(BACKGROUND_CONTEXT);
expect(revisions).toEqual([1, 2]);
await namespace.rebind(true, BACKGROUND_CONTEXT);
expect(models.state.value?.revision).toBe(3);
expect(revisions).toEqual([1, 2, 3]);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("hydrates keyed state before observe handlers and fences reused keys", async () => {
const provider = new RemoteServiceProvider([{ service: QuestionDialogs, mode: "keyed" }]);
expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]);
const transport = createLoopbackServiceTransport(provider);
const errors: Error[] = [];
const namespace = createRemoteServiceBinding({
services: [QuestionDialogs],
transport,
onError: (error) => errors.push(error),
});
const observed: {
question: Question | undefined;
service: QuestionDialogs;
context: Context;
}[] = [];
const stop = namespace.observe(QuestionDialogs, (service, context) => {
observed.push({
question: service.request.value,
service,
context,
});
});
await vi.waitFor(() => expect(errors).toEqual([]));
const firstRequest = replicatedState<Question>({ question: "First?" });
const firstSubmit = vi.fn(async () => ({ accepted: true }));
const closeFirst = provider.spawn(QuestionDialogs, "invocation-1", {
request: firstRequest,
submit: firstSubmit,
});
await vi.waitFor(() => expect(observed).toHaveLength(1));
expect(observed[0]).toMatchObject({ question: { question: "First?" } });
const firstService = observed[0]!.service;
firstRequest.state.question = "Updated?";
firstRequest.publish(BACKGROUND_CONTEXT);
expect(firstService.request.value).toEqual({ question: "Updated?" });
await expect(firstService.submit("yes", BACKGROUND_CONTEXT)).resolves.toEqual({ accepted: true });
expect(firstSubmit).toHaveBeenCalledWith("yes", expect.objectContaining({ abortSignal: undefined }));
const retainedFirstSubmit = firstService.submit;
closeFirst();
expect(observed[0]!.context.abortSignal?.aborted).toBe(true);
expect(() => firstService.request.value).toThrow("observation is closed");
expect(() => retainedFirstSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
const secondRequest = replicatedState<Question>({ question: "Again?" });
const closeSecond = provider.spawn(QuestionDialogs, "invocation-1", {
request: secondRequest,
async submit() {
return { accepted: false };
},
});
await vi.waitFor(() => expect(observed).toHaveLength(2));
expect(observed[1]).toMatchObject({ question: { question: "Again?" } });
expect(Object.is(observed[1]!.service, firstService)).toBe(false);
expect(errors).toEqual([]);
const secondService = observed[1]!.service;
const retainedSecondSubmit = secondService.submit;
stop();
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
expect(() => secondService.request.value).toThrow("observation is closed");
expect(() => retainedSecondSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
closeSecond();
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("rejects mode mixing and unsupported members", () => {
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
provider.provide(Models, {
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
async select() {},
});
expect(() => provider.spawn(Models, "wrong", {} as Models)).toThrow(/singleton/);
expect(() =>
provider.spawn(QuestionDialogs, "invalid", {
request: new Date() as unknown as ReplicatedState<Question>,
async submit() {
return { accepted: true };
},
}),
).toThrow(/not remotely exposable/);
provider.dispose();
});
});