mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The pure swap is gone: a reload restarts every live object of a changed plugin (documents and tables kept). Calls running at the commit and every call they cause (the ctx call chain; an old flag on call from trusted links) run on the open old attempt, which seals once its work drained and its successor's recorded requests are ready, bounded by reloadDrainTimeout (5 s); then the successor's factory runs. New work waits at the gate; same-host consumers see the draining object live; a reload cycle fails at once with unavailable/reload_cycle. Example 64, design section 21 rewritten.
1066 lines
41 KiB
TypeScript
1066 lines
41 KiB
TypeScript
// Test support: cost counters through the in-memory telemetry adapter, GC checks, and hosts, links, watches and
|
|
// rejections for the slice tests.
|
|
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { setFlagsFromString } from "node:v8";
|
|
import { runInNewContext } from "node:vm";
|
|
import { InMemoryTelemetryContext, type TelemetryContext } from "@earendil-works/pi-telemetry";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
import { BACKGROUND_CONTEXT, type Context, withTelemetry } from "../src/context/index.ts";
|
|
import {
|
|
type Address,
|
|
arr,
|
|
bool,
|
|
bytes,
|
|
type Contract,
|
|
hostKind,
|
|
int,
|
|
local,
|
|
method,
|
|
obj,
|
|
opt,
|
|
type ParamsOf,
|
|
remote,
|
|
str,
|
|
} from "../src/contracts/index.ts";
|
|
import { currentVariants, variantNames, variantOrder } from "../src/contracts/internal.ts";
|
|
import { ChordError, isChordError, PluginError, type Principal } from "../src/data/index.ts";
|
|
import { PROTOCOL_VERSION } from "../src/data/internal.ts";
|
|
import type { Op } from "../src/delta/index.ts";
|
|
import { defineState } from "../src/documents/index.ts";
|
|
import type { Attempt } from "../src/host/attempt.ts";
|
|
import {
|
|
type Ctx,
|
|
createHost,
|
|
defineFacet,
|
|
type FacetHost,
|
|
type FacetSelf,
|
|
type Host,
|
|
type HostError,
|
|
type PluginFailure,
|
|
type RefState,
|
|
type RouteHandle,
|
|
type Self,
|
|
type StoreSelf,
|
|
} from "../src/host/index.ts";
|
|
import type { ChordObject } from "../src/host/object.ts";
|
|
import type { JsonValue } from "../src/json.ts";
|
|
import { type Compression, type Link, memoryLinkPair, type PartLimits } from "../src/link/index.ts";
|
|
import {
|
|
binaryCodec,
|
|
type Codec,
|
|
checkMessage,
|
|
jsonPart,
|
|
type Message,
|
|
openPart,
|
|
type Part,
|
|
parsePart,
|
|
textCodec,
|
|
} from "../src/link/internal.ts";
|
|
import { nodeCompression } from "../src/node/compression.ts";
|
|
import { NodeSqliteDatabase, openNodeSqliteDatabase } from "../src/sqlite/node.ts";
|
|
import type { ArchivePart } from "../src/store/archive.ts";
|
|
import { sqliteBackend } from "../src/store/backend.ts";
|
|
import { changeDocument, createDocument, retireDocument } from "../src/store/engine.ts";
|
|
import { json } from "../src/store/json-column.ts";
|
|
import { LiveQueries } from "../src/store/live-query.ts";
|
|
import type { StagedWrite } from "../src/store/step.ts";
|
|
import { type ChordStore, openStore, type Store } from "../src/store/store.ts";
|
|
import { defineTable, type TableDefinition } from "../src/store/table.ts";
|
|
import { TableChanges } from "../src/store/table-changes.ts";
|
|
import { evolveTable } from "../src/store/table-evolve.ts";
|
|
import { TableHandle, type TableOwner } from "../src/store/table-handle.ts";
|
|
import { prepareTables } from "../src/store/table-migration.ts";
|
|
import type { NativeWatchHandle, WatchCall, WatchHandle } from "../src/watch.ts";
|
|
|
|
/** Counts of the `chord.data.*` counters. */
|
|
export type Counts = { walks: number; validations: number; compilations: number };
|
|
|
|
/** Runs `work` with a counting context and returns the counters it added. */
|
|
export async function counted(work: (context: Context) => void): Promise<Counts> {
|
|
const telemetry = new InMemoryTelemetryContext();
|
|
await telemetry.startSpan({ name: "test" }, (span) => work(withTelemetry(span, BACKGROUND_CONTEXT)));
|
|
const counts: Counts = { walks: 0, validations: 0, compilations: 0 };
|
|
for (const { name, value } of telemetry.getMetrics()) {
|
|
const counter = name.replace("chord.data.", "") as keyof Counts;
|
|
counts[counter] += value;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
setFlagsFromString("--expose-gc");
|
|
const gc = runInNewContext("gc") as () => void;
|
|
|
|
/** Expects every referent to be collected once nothing else holds it. */
|
|
export async function expectCollected(...refs: WeakRef<object>[]): Promise<void> {
|
|
for (let round = 0; round < 10 && refs.some((ref) => ref.deref() !== undefined); round++) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
gc();
|
|
}
|
|
expect(refs.map((ref) => ref.deref() === undefined)).toEqual(refs.map(() => true));
|
|
}
|
|
|
|
/** The heap in use once pending work ran and garbage was collected, repeatedly. */
|
|
export async function settledHeap(): Promise<number> {
|
|
for (let round = 0; round < 5; round++) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
gc();
|
|
}
|
|
return process.memoryUsage().heapUsed;
|
|
}
|
|
|
|
/** `true` when `A` and `B` are the same type; for type-level tests where `expectTypeOf` cannot compare (bytes). */
|
|
export type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
|
|
/** A telemetry adapter, a context counting through it, and the counters recorded so far, by name. */
|
|
export async function metered(): Promise<{
|
|
readonly telemetry: InMemoryTelemetryContext;
|
|
readonly context: Context;
|
|
counts(): Record<string, number>;
|
|
}> {
|
|
const telemetry = new InMemoryTelemetryContext();
|
|
// A settled span still records through its adapter's metrics.
|
|
const span = await telemetry.startSpan({ name: "test" }, (span) => span);
|
|
const counts = (): Record<string, number> => {
|
|
const result: Record<string, number> = {};
|
|
for (const { name, value } of telemetry.getMetrics()) result[name] = (result[name] ?? 0) + value;
|
|
return result;
|
|
};
|
|
return { telemetry, context: withTelemetry(span, BACKGROUND_CONTEXT), counts };
|
|
}
|
|
|
|
/** Waits until queued watch frames have been delivered. */
|
|
export function delivered(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
/** Starts `watch` and collects its frames as `[value, ops]`. */
|
|
export function framesOf<T>(watch: WatchHandle<T>): [T, readonly Op[]][] {
|
|
const frames: [T, readonly Op[]][] = [];
|
|
watch.start(async (call) => {
|
|
if (call.kind === "value") frames.push([call.value, call.ops]);
|
|
});
|
|
return frames;
|
|
}
|
|
|
|
/** Starts `watch` and collects every call it gets: values (the `retired` flag included) and failures. */
|
|
export function changesOf<T>(watch: WatchHandle<T>): WatchCall<T>[] {
|
|
const calls: WatchCall<T>[] = [];
|
|
watch.start(async (call) => {
|
|
calls.push(call);
|
|
});
|
|
return calls;
|
|
}
|
|
|
|
/** The values of `calls` (a failure as `{ failed: message }`), for short expectations. */
|
|
export function valuesOf<T>(calls: readonly WatchCall<T>[]): (T | { readonly failed: string })[] {
|
|
return calls.map((call) => (call.kind === "value" ? call.value : { failed: (call.error as Error).message }));
|
|
}
|
|
|
|
/**
|
|
* Fails the live attempt `self` as a runtime failure of its owner does (slice 6a §1.4); `objects` is the test file's
|
|
* `instancesOf(ChordObject, "attempt")`, whose objects other than `self`'s ignore it.
|
|
*/
|
|
export function failLive(objects: readonly WeakRef<ChordObject>[], self: Self, error: ChordError): void {
|
|
// The factory's `self` is its attempt.
|
|
for (const object of objects) object.deref()?.failLive(self as unknown as Attempt, { kind: "broken", error });
|
|
}
|
|
|
|
/** A local type whose objects' attempts a test ends by hand (`restartable`); it ends with its last reference. */
|
|
export const Restartable = local("app.restartable", {
|
|
version: 1,
|
|
params: { id: int() },
|
|
members: { touch: method() },
|
|
});
|
|
|
|
/** A local type with empty params whose attempts a test ends by hand (`storeRestartable`): it may persist (P3). */
|
|
export const StoreRestartable = local("app.store", { version: 1, params: {}, members: { touch: method() } });
|
|
|
|
/**
|
|
* Serves `Restartable` with `factory` per attempt (its number from 1) and gets object 1 live. `fail()` fails the live
|
|
* attempt as a runtime failure of its owner does (slice 6a §1.4); `retry()` asks for the next attempt and resolves
|
|
* once it is live; `restart()` does both. `objects` is the test file's `instancesOf(ChordObject, "attempt")`; `serve` serves other
|
|
* types on the same host.
|
|
*/
|
|
export function restartable(
|
|
objects: readonly WeakRef<ChordObject>[],
|
|
factory: (attempt: number, self: Self) => void,
|
|
options: Parameters<typeof loaded>[1] & { readonly serve?: (host: FacetHost) => void } = {},
|
|
) {
|
|
return restartableOf(Restartable, { id: 1 }, objects, factory, options);
|
|
}
|
|
|
|
/** As `restartable`, for `StoreRestartable`: its factories may declare tables and persisted documents. */
|
|
export function storeRestartable(
|
|
objects: readonly WeakRef<ChordObject>[],
|
|
factory: (attempt: number, self: StoreSelf) => void,
|
|
options: Parameters<typeof loaded>[1] & { readonly serve?: (host: FacetHost) => void } = {},
|
|
) {
|
|
return restartableOf(StoreRestartable, {}, objects, factory as (attempt: number, self: Self) => void, options);
|
|
}
|
|
|
|
async function restartableOf<C extends typeof Restartable | typeof StoreRestartable>(
|
|
Type: C,
|
|
params: ParamsOf<C>,
|
|
objects: readonly WeakRef<ChordObject>[],
|
|
factory: (attempt: number, self: Self) => void,
|
|
options: Parameters<typeof loaded>[1] & { readonly serve?: (host: FacetHost) => void },
|
|
) {
|
|
const attempts: Self[] = [];
|
|
let started = 0;
|
|
const { host, errors } = await loaded((h) => {
|
|
options.serve?.(h);
|
|
// Both types have the one method `touch`; the factory's `self` is the attempt either way.
|
|
h.serve(Type as typeof Restartable, (_, self) => {
|
|
factory(++started, self);
|
|
attempts.push(self);
|
|
return { touch: async () => null };
|
|
});
|
|
}, options);
|
|
const get = () => host.get(Type as typeof Restartable, params as ParamsOf<typeof Restartable>, BACKGROUND_CONTEXT);
|
|
const reference = await get();
|
|
const fail = (): void => {
|
|
failLive(objects, attempts.at(-1)!, new ChordError({ code: "unavailable", message: "boom" }));
|
|
};
|
|
const retry = async (): Promise<void> => {
|
|
reference.retry();
|
|
(await get()).release();
|
|
};
|
|
const restart = async (): Promise<void> => {
|
|
fail();
|
|
await retry();
|
|
};
|
|
return { host, errors, reference, attempts, fail, retry, restart };
|
|
}
|
|
|
|
/** A host with background errors collected, loaded with one facet (`@test/app`) whose setup is `setup`. */
|
|
export async function loaded(
|
|
setup: (host: FacetHost, self: FacetSelf) => void,
|
|
options: {
|
|
readonly telemetry?: InMemoryTelemetryContext;
|
|
readonly context?: Context;
|
|
readonly closeTimeout?: number;
|
|
readonly readyTimeout?: number;
|
|
readonly reloadDrainTimeout?: number;
|
|
readonly store?: Store;
|
|
} = {},
|
|
): Promise<{ readonly host: Host; readonly errors: HostError[]; readonly failed: readonly PluginFailure[] }> {
|
|
const errors: HostError[] = [];
|
|
const { telemetry, closeTimeout, readyTimeout, reloadDrainTimeout, store } = options;
|
|
const onError = (error: HostError) => errors.push(error);
|
|
const host = createHost({ telemetry, closeTimeout, readyTimeout, reloadDrainTimeout, store, onError });
|
|
const facets = [{ name: "@test/app", facet: defineFacet(setup) }];
|
|
const { failed } = await host.load(facets, options.context ?? BACKGROUND_CONTEXT);
|
|
return { host, errors, failed };
|
|
}
|
|
|
|
/**
|
|
* A host with background errors collected, loaded with one facet per plugin, in the order given (`setups` by plugin
|
|
* name).
|
|
*/
|
|
export async function loadedPlugins(
|
|
setups: Readonly<Record<string, (host: FacetHost, self: FacetSelf) => void>>,
|
|
options: { readonly telemetry?: InMemoryTelemetryContext; readonly readyTimeout?: number } = {},
|
|
): Promise<{ readonly host: Host; readonly errors: HostError[]; readonly failed: readonly PluginFailure[] }> {
|
|
const errors: HostError[] = [];
|
|
const host = createHost({ ...options, onError: (error) => errors.push(error) });
|
|
const facets = Object.entries(setups).map(([name, setup]) => ({ name, facet: defineFacet(setup) }));
|
|
const { failed } = await host.load(facets, BACKGROUND_CONTEXT);
|
|
return { host, errors, failed };
|
|
}
|
|
|
|
/** A store on a fresh SQLite `:memory:` database, as Chord's layers see it. */
|
|
export async function memoryStore(options?: { readonly outbox?: boolean }): Promise<ChordStore> {
|
|
const db = await openNodeSqliteDatabase(":memory:");
|
|
return (await openStore(sqliteBackend(db), BACKGROUND_CONTEXT, options)) as ChordStore;
|
|
}
|
|
|
|
/** A statement of the Node facade's transactions a test can make fail. */
|
|
export type TransactionStatement = "BEGIN IMMEDIATE" | "COMMIT" | "ROLLBACK";
|
|
|
|
/**
|
|
* A Node SQLite facade on `:memory:` whose next `statement` throws, once per `fail` (the D4 matrix: a failed
|
|
* `BEGIN`, `COMMIT` or `ROLLBACK`, as the real facade sees one).
|
|
*/
|
|
export function faultyDatabase(): { readonly db: NodeSqliteDatabase; fail(statement: TransactionStatement): void } {
|
|
const database = new DatabaseSync(":memory:");
|
|
const failing = new Set<string>();
|
|
const exec = database.exec.bind(database);
|
|
database.exec = (sql: string): void => {
|
|
if (!failing.delete(sql)) {
|
|
exec(sql);
|
|
return;
|
|
}
|
|
// A failed `ROLLBACK` here still rolls back, so the database stays usable for the rest of the test.
|
|
if (sql === "ROLLBACK") exec(sql);
|
|
throw new Error(`injected: ${sql} failed`);
|
|
};
|
|
return { db: new NodeSqliteDatabase(database), fail: (statement) => void failing.add(statement) };
|
|
}
|
|
|
|
/** A store over `faultyDatabase()`, and its `fail`. */
|
|
export async function faultyStore(options?: {
|
|
readonly outbox?: boolean;
|
|
}): Promise<{ readonly store: ChordStore; fail(statement: TransactionStatement): void }> {
|
|
const { db, fail } = faultyDatabase();
|
|
return { store: (await openStore(sqliteBackend(db), BACKGROUND_CONTEXT, options)) as ChordStore, fail };
|
|
}
|
|
|
|
/**
|
|
* Holds `store`'s next commit step inside its backend transaction, after its admission: `entered` resolves there,
|
|
* `release()` lets it finish; later commits run as usual.
|
|
*/
|
|
export function holdNextCommit(store: ChordStore): { readonly entered: Promise<void>; release(): void } {
|
|
const gate = Promise.withResolvers<void>();
|
|
const entered = Promise.withResolvers<void>();
|
|
const commit = store.commit.bind(store);
|
|
store.commit = (writes, fence, ctx) => {
|
|
store.commit = commit;
|
|
const held: StagedWrite = {
|
|
order: "write",
|
|
apply: async () => {
|
|
entered.resolve();
|
|
await gate.promise;
|
|
return [{ kind: "dropIndex", table: "x", name: "y" }];
|
|
},
|
|
};
|
|
return commit([...writes, held], fence, ctx);
|
|
};
|
|
return { entered: entered.promise, release: () => gate.resolve() };
|
|
}
|
|
|
|
/** The owner of a test's table handles: its failures collected. */
|
|
export function tableOwner(failures: ChordError[] = []): TableOwner & { readonly failures: ChordError[] } {
|
|
return {
|
|
label: "app.store",
|
|
scope: {},
|
|
queries: new LiveQueries(),
|
|
assertLive: () => {},
|
|
fail: (error) => void failures.push(error),
|
|
failures,
|
|
};
|
|
}
|
|
|
|
/** Creates `definition` (and its indexes) in `store` as `app.store:<name>` and returns a handle to it. */
|
|
export async function tableIn(
|
|
store: ChordStore,
|
|
definition: TableDefinition,
|
|
owner = tableOwner(),
|
|
): Promise<TableHandle> {
|
|
const table = `app.store:${definition.name}`;
|
|
const fence = { admit: () => true, adopt: () => {} };
|
|
await prepareTables([{ definition, table }], store, fence, "app.store", BACKGROUND_CONTEXT);
|
|
return new TableHandle(definition, table, store, owner);
|
|
}
|
|
|
|
/** The error `promise` rejects with, as ChordError JSON when it is one. */
|
|
export async function rejection(promise: Promise<unknown>): Promise<unknown> {
|
|
try {
|
|
await promise;
|
|
} catch (error) {
|
|
return isChordError(error) ? error.toJSON() : error;
|
|
}
|
|
throw new Error("expected a rejection");
|
|
}
|
|
|
|
/** Fakes `setTimeout` and `Date` (from 0) in the calling `describe`; `setImmediate` stays real for `flushed()`. */
|
|
export function fakeTimers(): void {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], now: 0 });
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
}
|
|
|
|
/** Waits until every pending promise callback has run. */
|
|
export function flushed(): Promise<void> {
|
|
return new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
/** The kinds and objects of the reported `activation_failed` errors, with their causes' kinds. */
|
|
export function failures(errors: readonly HostError[]): string[] {
|
|
return errors.flatMap((error) =>
|
|
error.kind === "activation_failed" ? [`${error.object}: ${error.failure.kind}`] : [],
|
|
);
|
|
}
|
|
|
|
/** The principals of the remote tests. */
|
|
export type TestPrincipal =
|
|
| { kind: "owner" }
|
|
| { kind: "guest"; session: string }
|
|
| { kind: "server" }
|
|
| { kind: "worker"; session: string };
|
|
|
|
/**
|
|
* A server (principal `server`, loaded with one facet whose setup is `setup`) and a client connected to it, the
|
|
* client's link stamped `child`; background errors collected per host.
|
|
*/
|
|
export async function linked(
|
|
setup: (host: FacetHost<TestPrincipal>) => void,
|
|
options: {
|
|
readonly child?: TestPrincipal;
|
|
readonly context?: Context;
|
|
readonly telemetry?: InMemoryTelemetryContext;
|
|
readonly readyTimeout?: number;
|
|
} = {},
|
|
): Promise<{
|
|
readonly server: Host<TestPrincipal>;
|
|
readonly client: Host<TestPrincipal>;
|
|
readonly errors: { readonly server: HostError[]; readonly client: HostError[] };
|
|
readonly links: readonly [Link, Link];
|
|
}> {
|
|
const context = options.context ?? BACKGROUND_CONTEXT;
|
|
const errors = { server: [] as HostError[], client: [] as HostError[] };
|
|
const { telemetry, readyTimeout } = options;
|
|
const server = createHost<TestPrincipal>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
telemetry,
|
|
readyTimeout,
|
|
onError: (error) => errors.server.push(error),
|
|
});
|
|
await server.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>(setup) }], context);
|
|
const client = createHost<TestPrincipal>({ telemetry, readyTimeout, onError: (error) => errors.client.push(error) });
|
|
await client.load([], context);
|
|
const links = memoryLinkPair();
|
|
server.accept(links[0], { principal: options.child ?? { kind: "owner" }, codec: testCodec() }, context);
|
|
client.connect(links[1], { principal: { kind: "server" }, codec: testCodec() }, context);
|
|
return { server, client, errors, links };
|
|
}
|
|
|
|
/** The incarnation a raw peer greets with. */
|
|
export const RAW_PEER = "00000000-0000-7000-8000-000000000000";
|
|
|
|
/**
|
|
* A peer speaking the wire by hand, in the current test codec, at the other end of `link`: it sends `hello` (unless
|
|
* `hello: false`, announcing `calls`, default 256), records what arrives, acks every `bound` and `state` with its origin
|
|
* (unless `ack: false`: then `ack()` sends one), and sends what a test forges.
|
|
*/
|
|
export class RawPeer {
|
|
readonly received: Message[] = [];
|
|
closed = false;
|
|
readonly codec: Codec;
|
|
readonly #link: Link;
|
|
#waiters: (() => void)[] = [];
|
|
|
|
constructor(
|
|
link: Link,
|
|
options: {
|
|
readonly hello?: boolean;
|
|
readonly calls?: number;
|
|
readonly ack?: boolean;
|
|
readonly codec?: Codec;
|
|
} = {},
|
|
) {
|
|
this.#link = link;
|
|
this.codec = options.codec ?? testCodec();
|
|
link.onMessage((bytes) => {
|
|
const message = checkMessage(this.codec.decode(bytes), BACKGROUND_CONTEXT);
|
|
this.received.push(message);
|
|
if (options.ack !== false && (message.kind === "bound" || message.kind === "state")) this.ack(message.origin);
|
|
for (const wake of this.#waiters.splice(0)) wake();
|
|
});
|
|
link.onClose(() => {
|
|
this.closed = true;
|
|
for (const wake of this.#waiters.splice(0)) wake();
|
|
});
|
|
if (options.hello !== false) {
|
|
this.send({ kind: "hello", protocol: PROTOCOL_VERSION, incarnation: RAW_PEER, calls: options.calls ?? 256 });
|
|
}
|
|
}
|
|
|
|
send(message: Message): void {
|
|
this.#link.send(this.codec.encode(message));
|
|
}
|
|
|
|
/** Acknowledges one `bound` or `state` message of `origin` (none: the peer's own). */
|
|
ack(origin?: number): void {
|
|
this.send(origin === undefined ? { kind: "ack" } : { kind: "ack", origin });
|
|
}
|
|
|
|
close(): void {
|
|
this.#link.close();
|
|
}
|
|
|
|
/** Sends a message as raw bytes. */
|
|
sendBytes(message: Uint8Array): void {
|
|
this.#link.send(message);
|
|
}
|
|
|
|
/** The first received message of `kind` not taken yet, waiting for it; throws if the link closes first. */
|
|
async next<K extends Message["kind"]>(kind: K): Promise<Extract<Message, { kind: K }>> {
|
|
for (;;) {
|
|
const index = this.received.findIndex((message) => message.kind === kind);
|
|
if (index >= 0) return this.received.splice(index, 1)[0] as Extract<Message, { kind: K }>;
|
|
if (this.closed) throw new Error(`link closed before ${kind}`);
|
|
await new Promise<void>((resolve) => this.#waiters.push(resolve));
|
|
}
|
|
}
|
|
|
|
/** The kinds received and not taken, in order. */
|
|
kinds(): string[] {
|
|
return this.received.map(({ kind }) => kind);
|
|
}
|
|
}
|
|
|
|
/** A part's value as its owner reads it: opened (inflated when deflated) without limits, and parsed. */
|
|
export function partValue(part: Part): unknown {
|
|
const unlimited = { part: Number.POSITIVE_INFINITY, decoded: Number.POSITIVE_INFINITY };
|
|
return parsePart(openPart(part, unlimited, nodeCompression, BACKGROUND_CONTEXT));
|
|
}
|
|
|
|
/** The codecs every protocol test runs with. */
|
|
export const CODECS: readonly Codec[] = [binaryCodec, textCodec];
|
|
|
|
let currentCodec: Codec = binaryCodec;
|
|
|
|
/** The codec of the protocol test running now (`forEachCodec`); links in tests use it at both ends. */
|
|
export function testCodec(): Codec {
|
|
return currentCodec;
|
|
}
|
|
|
|
/** Runs `suite` once per codec, as `binary codec` and `text codec`. */
|
|
export function forEachCodec(suite: () => void): void {
|
|
for (const codec of CODECS) {
|
|
describe(`${codec.name} codec`, () => {
|
|
beforeAll(() => {
|
|
currentCodec = codec;
|
|
});
|
|
suite();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** The wire's indexes of a remote contract's variant (2b): the variant's, and its state members' and methods' by name. */
|
|
export function wireNames(Type: Contract, variant: string) {
|
|
const names = variantNames(currentVariants(Type).get(variant)!);
|
|
const indexes = (list: readonly string[]) => Object.fromEntries(list.map((name, index) => [name, index]));
|
|
return {
|
|
variant: variantOrder(Type).indexOf(variant),
|
|
state: indexes(names.state),
|
|
methods: indexes(names.methods),
|
|
/** A message's members by index as an object by name, their parts parsed. */
|
|
named: (members: readonly { readonly member: number; readonly ops?: Part; readonly value?: Part }[]) =>
|
|
Object.fromEntries(members.map(({ member, ops, value }) => [names.state[member], partValue((ops ?? value)!)])),
|
|
};
|
|
}
|
|
|
|
/** A server host as in `linked` and a raw peer on a link it accepted as `child` (trusted when `trusted`), with `limits`. */
|
|
export async function rawLinked(
|
|
setup: (host: FacetHost<TestPrincipal>) => void,
|
|
options: {
|
|
readonly child?: Principal;
|
|
readonly hello?: boolean;
|
|
readonly readyTimeout?: number;
|
|
readonly limits?: PartLimits;
|
|
readonly compression?: Compression;
|
|
/** What the raw peer is to the host: a child (`accept`, the default) or its parent (`connect`). */
|
|
readonly role?: "child" | "parent";
|
|
readonly telemetry?: TelemetryContext;
|
|
/** Whether the host trusts the raw peer's link (its messages are not checked; `call.old` is honoured). */
|
|
readonly trusted?: boolean;
|
|
} = {},
|
|
): Promise<{ readonly server: Host<TestPrincipal>; readonly peer: RawPeer; readonly errors: HostError[] }> {
|
|
const errors: HostError[] = [];
|
|
const server = createHost<TestPrincipal>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
readyTimeout: options.readyTimeout,
|
|
compression: options.compression,
|
|
telemetry: options.telemetry,
|
|
onError: (error) => errors.push(error),
|
|
});
|
|
await server.load([{ name: "@test/app", facet: defineFacet<TestPrincipal>(setup) }], BACKGROUND_CONTEXT);
|
|
const [a, b] = memoryLinkPair();
|
|
const child = (options.child ?? { kind: "owner" }) as TestPrincipal;
|
|
const link = { principal: child, codec: testCodec(), limits: options.limits, trusted: options.trusted };
|
|
if (options.role === "parent") server.connect(a, link, BACKGROUND_CONTEXT);
|
|
else server.accept(a, link, BACKGROUND_CONTEXT);
|
|
return { server, peer: new RawPeer(b, { hello: options.hello }), errors };
|
|
}
|
|
|
|
/** Waits until messages and closes in flight on memory links (one task per hop) have arrived. */
|
|
export async function transported(): Promise<void> {
|
|
for (let hop = 0; hop < 6; hop++) await flushed();
|
|
}
|
|
|
|
/** The variants of the notes types: an editor variant for owners, a reader one for guests. */
|
|
const NOTES_VARIANTS = {
|
|
editor: {
|
|
state: { notes: arr(obj({ text: str() })), count: int() },
|
|
methods: {
|
|
add: method(obj({ text: str() }), int()),
|
|
fail: method(obj({ how: str() })),
|
|
wait: method(),
|
|
echo: method(obj({ data: bytes() }), bytes()),
|
|
},
|
|
},
|
|
reader: { state: { notes: arr(obj({ text: str() })), count: int() }, methods: { wait: method() } },
|
|
};
|
|
|
|
/** The host kind of the test servers, where every test type but `PlacedNotes` lives. */
|
|
export const Server = hostKind("server");
|
|
|
|
/** `Server()` as a hold names it on the wire: the address of every test type served on the server. */
|
|
export const SERVER_ADDRESS = { kind: "server", params: {} };
|
|
|
|
/** The remote type of the link tests: notes per session, an editor variant for owners, a reader one for guests. */
|
|
export const Notes = remote("app.notes", {
|
|
version: 1,
|
|
params: { session: str() },
|
|
hostAddress: () => Server(),
|
|
variants: NOTES_VARIANTS,
|
|
});
|
|
|
|
/** The kind of the relay tests' workers: one per session. */
|
|
export const Session = hostKind("session", { id: str() });
|
|
|
|
/** `Notes` placed on the session's worker: the relay tests' type. */
|
|
export const PlacedNotes = remote("app.placed-notes", {
|
|
version: 1,
|
|
params: { session: str() },
|
|
hostAddress: ({ session }) => Session({ id: session }),
|
|
variants: NOTES_VARIANTS,
|
|
});
|
|
|
|
const NotesDoc = defineState("notes", {
|
|
schema: obj({ list: arr(obj({ text: str() })) }),
|
|
version: 1,
|
|
initial: () => ({ list: [] }),
|
|
});
|
|
|
|
/** The shared store behind `Notes` (§6): the notes of a session, once, whoever views them. */
|
|
export const NoteStore = local("app.note-store", {
|
|
version: 1,
|
|
params: { session: str() },
|
|
members: { list: arr(obj({ text: str() })), add: method(obj({ text: str() }), int()) },
|
|
});
|
|
|
|
/** What `serveNotes` records: the handlers' contexts of `wait` calls, and every factory run of `Notes`. */
|
|
export type NotesProbe = { readonly waits: Ctx<TestPrincipal>[]; readonly factories: string[] };
|
|
|
|
/**
|
|
* Serves `NoteStore` and `Notes` over it: `notes` and `count` are derives over the store's list; guests get the reader
|
|
* variant. `wait` resolves when its context is aborted; `fail` throws a ChordError (`chord`), a PluginError (`plugin`)
|
|
* or a plain Error.
|
|
*/
|
|
export function serveNotes(
|
|
host: FacetHost<TestPrincipal>,
|
|
Type: typeof Notes | typeof PlacedNotes = Notes,
|
|
): NotesProbe {
|
|
const probe: NotesProbe = { waits: [], factories: [] };
|
|
host.serve(NoteStore, (_, self) => {
|
|
const doc = self.state(NotesDoc);
|
|
return {
|
|
list: self.derive([doc], ([d]) => d.list),
|
|
add: async ({ text }, ctx) => {
|
|
await doc.change((draft) => void draft.list.push({ text }), ctx);
|
|
return doc.value.list.length;
|
|
},
|
|
};
|
|
});
|
|
// The two types differ in their name and host address only.
|
|
host.serve(Type as typeof Notes, ({ session }, principal, self) => {
|
|
probe.factories.push(`${session} ${principal.kind}`);
|
|
const store = self.get(NoteStore, { session });
|
|
const notes = self.derive([store.list], ([list]) => list);
|
|
const count = self.derive([store.list], ([list]) => list.length);
|
|
const wait = async (ctx: Ctx<TestPrincipal>) => {
|
|
probe.waits.push(ctx);
|
|
await new Promise((resolve) => ctx.abortSignal?.addEventListener("abort", resolve));
|
|
return null;
|
|
};
|
|
if (principal.kind === "guest") return { variant: "reader", members: { notes, count, wait } };
|
|
return {
|
|
variant: "editor",
|
|
members: {
|
|
notes,
|
|
count,
|
|
wait,
|
|
add: async (args, ctx) => store.add(args, ctx),
|
|
fail: async ({ how }) => {
|
|
if (how === "chord") throw new ChordError({ code: "forbidden", message: "not yours" });
|
|
if (how === "plugin") throw new PluginError("quota", "too many", { limit: 1 });
|
|
throw new Error("plain");
|
|
},
|
|
echo: async ({ data }) => data,
|
|
},
|
|
};
|
|
});
|
|
return probe;
|
|
}
|
|
|
|
/** Adds a note to the session's store from the server itself; the reference it takes keeps the store. */
|
|
export async function addNote(server: Host<TestPrincipal>, session: string, text: string): Promise<void> {
|
|
const store = await server.get(NoteStore, { session }, BACKGROUND_CONTEXT);
|
|
await store.add({ text }, BACKGROUND_CONTEXT);
|
|
}
|
|
|
|
/** A client connected to a raw peer playing the provider (the link's limits `limits`), after the handshake. */
|
|
export async function rawProvider(
|
|
options: {
|
|
readonly readyTimeout?: number;
|
|
readonly limits?: PartLimits;
|
|
readonly compression?: Compression;
|
|
readonly telemetry?: TelemetryContext;
|
|
} = {},
|
|
) {
|
|
const errors: HostError[] = [];
|
|
const client = createHost<TestPrincipal>({
|
|
readyTimeout: options.readyTimeout,
|
|
compression: options.compression,
|
|
telemetry: options.telemetry,
|
|
onError: (error) => errors.push(error),
|
|
});
|
|
await client.load([], BACKGROUND_CONTEXT);
|
|
const peer = await rawParent(client, options.limits);
|
|
return { client, peer, errors };
|
|
}
|
|
|
|
/**
|
|
* Connects `host` to a new raw peer playing its parent (a first link, or the next after a drop), the link's limits
|
|
* `limits`, after the handshake.
|
|
*/
|
|
export async function rawParent(host: Host<TestPrincipal>, limits?: PartLimits): Promise<RawPeer> {
|
|
const [a, b] = memoryLinkPair();
|
|
host.connect(a, { principal: { kind: "server" }, codec: testCodec(), limits }, BACKGROUND_CONTEXT);
|
|
const peer = new RawPeer(b);
|
|
await peer.next("hello");
|
|
return peer;
|
|
}
|
|
|
|
/** Starts a `ref.state` watch and collects the states it delivers, by kind (failed ones with their error's message). */
|
|
export function statesOf(watch: NativeWatchHandle<RefState>): string[] {
|
|
const states: string[] = [];
|
|
watch.start(async (call) => {
|
|
if (call.kind !== "value") return;
|
|
const state = call.value;
|
|
states.push(state.kind === "failed" ? `failed: ${state.cause.error.message}` : state.kind);
|
|
});
|
|
return states;
|
|
}
|
|
|
|
/** The wire's indexes of `Notes`'s editor variant. */
|
|
export const EDITOR = wireNames(Notes, "editor");
|
|
|
|
/** Answers the next hold with `bound`: the editor variant with `notes` and `count`. */
|
|
export async function bindNext(peer: RawPeer, notes: { text: string }[] = []) {
|
|
const { hold } = await peer.next("hold");
|
|
bindHold(peer, hold, notes);
|
|
return hold;
|
|
}
|
|
|
|
/** Sends `bound` for `hold`: the editor variant with `notes` and `count`. */
|
|
export function bindHold(peer: RawPeer, hold: number, notes: { text: string }[] = []): void {
|
|
const members = [
|
|
{ member: EDITOR.state.notes!, value: jsonPart(notes) },
|
|
{ member: EDITOR.state.count!, value: jsonPart(notes.length) },
|
|
];
|
|
peer.send({ kind: "bound", holds: [{ hold, variant: EDITOR.variant, members, failed: [] }] });
|
|
}
|
|
|
|
/**
|
|
* Records weakly every instance of a class that calls `method` from now on, for GC tests of objects a test cannot
|
|
* reach (holds, remote objects). Patches the prototype for the rest of the test file.
|
|
*/
|
|
export function instancesOf<T extends object>(
|
|
Class: { readonly prototype: T },
|
|
method: keyof T & string,
|
|
): WeakRef<T>[] {
|
|
const instances: WeakRef<T>[] = [];
|
|
const prototype = Class.prototype as Record<string, (...args: unknown[]) => unknown>;
|
|
const original = prototype[method]!;
|
|
prototype[method] = function (this: T, ...args: unknown[]) {
|
|
instances.push(new WeakRef(this));
|
|
return original.apply(this, args);
|
|
};
|
|
return instances;
|
|
}
|
|
|
|
/** What a route's `resolve` returns in the relay tests. */
|
|
export type Resolved = {
|
|
readonly link: Link;
|
|
readonly principal: TestPrincipal;
|
|
readonly trusted?: boolean;
|
|
readonly codec?: Codec;
|
|
readonly limits?: PartLimits;
|
|
};
|
|
|
|
/** What `relayed` builds: the hosts, the links the server holds, the resolves it ran and the errors per host. */
|
|
export type Relayed = {
|
|
readonly tab: Host<TestPrincipal>;
|
|
readonly server: Host<TestPrincipal>;
|
|
/** The server's route to `Session`'s addresses. */
|
|
readonly route: RouteHandle<typeof Session>;
|
|
/** The worker of each resolved session address, the latest one per key. */
|
|
readonly workers: Map<string, Host<TestPrincipal>>;
|
|
/** The server's end of each resolved route link, in order. */
|
|
readonly routeLinks: Link[];
|
|
/** The server's end of the tab's link. */
|
|
readonly tabLink: Link;
|
|
/** The address keys `resolve` ran for, in order. */
|
|
readonly resolves: string[];
|
|
readonly errors: { readonly tab: HostError[]; readonly server: HostError[]; readonly worker: HostError[] };
|
|
};
|
|
|
|
/**
|
|
* Three hosts: a tab connected to a server, and a server routing `Session` to an in-process worker per address (made
|
|
* by each `resolve`, connected before it loads, W6) that serves `PlacedNotes` and `Notes`'s store. The tab acts as
|
|
* `tab` (owner by default); `authorize` admits everyone by default; route links are trusted when `trusted`.
|
|
* `server` and `worker` add setups of their own; `resolve` wraps the default one (`worker()` makes the worker).
|
|
*/
|
|
export async function relayed(
|
|
options: {
|
|
readonly tab?: TestPrincipal;
|
|
readonly authorize?: (principal: TestPrincipal, address: { readonly key: string }) => boolean;
|
|
readonly trusted?: boolean;
|
|
readonly codecs?: { readonly tab?: Codec; readonly route?: Codec };
|
|
readonly server?: (host: FacetHost<TestPrincipal>) => void;
|
|
readonly worker?: (host: FacetHost<TestPrincipal>) => void;
|
|
readonly resolve?: (
|
|
address: Address<typeof Session>,
|
|
worker: () => Resolved,
|
|
context: Context,
|
|
) => Promise<Resolved>;
|
|
readonly context?: Context;
|
|
/** The server's and the workers' `revokedBy`. */
|
|
readonly revokedBy?: (principal: TestPrincipal) => readonly JsonValue[];
|
|
/** The server's `revokedBy` when it differs from the workers'. */
|
|
readonly serverRevokedBy?: (principal: TestPrincipal) => readonly JsonValue[];
|
|
/** The compressor of each host: tab, server, workers. */
|
|
readonly compression?: {
|
|
readonly tab?: Compression;
|
|
readonly server?: Compression;
|
|
readonly worker?: Compression;
|
|
};
|
|
/** The limits of the tab's link at the server. */
|
|
readonly limits?: PartLimits;
|
|
/** The telemetry adapter of each host: tab, server, workers. */
|
|
readonly telemetry?: {
|
|
readonly tab?: TelemetryContext;
|
|
readonly server?: TelemetryContext;
|
|
readonly worker?: TelemetryContext;
|
|
};
|
|
} = {},
|
|
): Promise<Relayed> {
|
|
const context = options.context ?? BACKGROUND_CONTEXT;
|
|
const errors = { tab: [] as HostError[], server: [] as HostError[], worker: [] as HostError[] };
|
|
const workers = new Map<string, Host<TestPrincipal>>();
|
|
const routeLinks: Link[] = [];
|
|
const resolves: string[] = [];
|
|
const { revokedBy, compression = {}, telemetry = {} } = options;
|
|
const server = createHost<TestPrincipal>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
revokedBy: options.serverRevokedBy ?? revokedBy,
|
|
compression: compression.server,
|
|
telemetry: telemetry.server,
|
|
onError: (error) => errors.server.push(error),
|
|
});
|
|
const codec = options.codecs?.route ?? testCodec();
|
|
const worker = (address: Address<typeof Session>): Resolved => {
|
|
resolves.push(address.key);
|
|
const [a, b] = memoryLinkPair();
|
|
const principal = { kind: "worker", session: address.params.id } as const;
|
|
const host = createHost<TestPrincipal>({
|
|
address,
|
|
principal,
|
|
revokedBy,
|
|
compression: compression.worker,
|
|
telemetry: telemetry.worker,
|
|
onError: (error) => errors.worker.push(error),
|
|
});
|
|
host.connect(b, { principal: { kind: "server" }, trusted: options.trusted, codec }, context);
|
|
const facet = defineFacet<TestPrincipal>((h) => {
|
|
serveNotes(h, PlacedNotes);
|
|
options.worker?.(h);
|
|
});
|
|
void host.load([{ name: "@test/worker", facet }], context);
|
|
workers.set(address.key, host);
|
|
routeLinks.push(a);
|
|
return { link: a, principal, trusted: options.trusted, codec };
|
|
};
|
|
const resolve = options.resolve ?? (async (_address, made) => made());
|
|
const route = server.route(
|
|
Session,
|
|
{
|
|
authorize: options.authorize ?? (() => true),
|
|
resolve: (address, routing) => resolve(address, () => worker(address), routing),
|
|
},
|
|
context,
|
|
);
|
|
await server.load(
|
|
[{ name: "@test/server", facet: defineFacet<TestPrincipal>((h) => options.server?.(h)) }],
|
|
context,
|
|
);
|
|
const tab = createHost<TestPrincipal>({
|
|
compression: compression.tab,
|
|
telemetry: telemetry.tab,
|
|
onError: (error) => errors.tab.push(error),
|
|
});
|
|
await tab.load([], context);
|
|
const [a, b] = memoryLinkPair();
|
|
const tabCodec = options.codecs?.tab ?? testCodec();
|
|
const accepted = { principal: options.tab ?? { kind: "owner" }, codec: tabCodec, limits: options.limits } as const;
|
|
server.accept(a, accepted, context);
|
|
tab.connect(b, { principal: { kind: "server" }, codec: tabCodec }, context);
|
|
return { tab, server, route, workers, routeLinks, tabLink: a, resolves, errors };
|
|
}
|
|
|
|
/**
|
|
* A `resolve` for `relayed` whose workers are raw peers playing the provider, collected in `peers` in order; their
|
|
* links trusted when `trusted`.
|
|
*/
|
|
export function rawWorkers(
|
|
peers: RawPeer[],
|
|
options: {
|
|
readonly calls?: number;
|
|
readonly ack?: boolean;
|
|
readonly trusted?: boolean;
|
|
readonly limits?: PartLimits;
|
|
} = {},
|
|
): (address: Address<typeof Session>) => Promise<Resolved> {
|
|
return async (address) => {
|
|
const [a, b] = memoryLinkPair();
|
|
peers.push(new RawPeer(b, options));
|
|
const principal = { kind: "worker", session: address.params.id } as const;
|
|
return { link: a, principal, codec: testCodec(), trusted: options.trusted, limits: options.limits };
|
|
};
|
|
}
|
|
|
|
/** A host's incarnation, which its handshakes send. */
|
|
export function incarnationOf(host: Host<TestPrincipal>): string {
|
|
// Every host is a `ChordHost`.
|
|
return (host as unknown as { readonly incarnation: string }).incarnation;
|
|
}
|
|
|
|
/** Counts the bytes every `DecompressionStream` puts out while installed; `restore` puts the real one back. */
|
|
export function countInflated(): { readonly bytes: number; restore(): void } {
|
|
const Real = globalThis.DecompressionStream;
|
|
const counter = { bytes: 0, restore: () => vi.unstubAllGlobals() };
|
|
class Counted {
|
|
readonly writable: WritableStream<BufferSource>;
|
|
readonly readable: ReadableStream<Uint8Array>;
|
|
constructor(format: CompressionFormat) {
|
|
const real = new Real(format);
|
|
this.writable = real.writable;
|
|
this.readable = real.readable.pipeThrough(
|
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
counter.bytes += chunk.byteLength;
|
|
controller.enqueue(chunk);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
vi.stubGlobal("DecompressionStream", Counted);
|
|
return counter;
|
|
}
|
|
|
|
/** The tables of `archiveStore`: edits (v1 with an index; v2 adds `done`) and notes, whose v2 migration crashed. */
|
|
export const ArchiveTables = (() => {
|
|
const EditsV1 = defineTable("edits", {
|
|
version: 1,
|
|
columns: {
|
|
id: str(),
|
|
path: str(),
|
|
at: int(),
|
|
note: opt(str()),
|
|
meta: opt(json(obj({ a: int() }), { version: 1 })),
|
|
},
|
|
key: ["id"],
|
|
indexes: { by_path: ["path", "at"] },
|
|
});
|
|
const EditsV2 = evolveTable(EditsV1, (t) => t.add("done", bool({ default: false })));
|
|
const NotesV1 = defineTable("notes", { version: 1, columns: { id: int(), text: str() }, key: ["id"] });
|
|
const NotesV2 = evolveTable(NotesV1, (t) =>
|
|
t.rows((row) => {
|
|
if (row.id === 120) throw new Error("crash");
|
|
return { text: `${row.text}!` };
|
|
}),
|
|
);
|
|
return { EditsV1, EditsV2, NotesV1, NotesV2 };
|
|
})();
|
|
|
|
/**
|
|
* A store holding a bit of everything an archive carries: `app.store:edits` at version 2 with 150 rows (two
|
|
* export pages; some written at version 1, `NULL` and `json(...)` cells, an index), `app.store:notes` left
|
|
* `migrating` at version 2 after one committed chunk, and documents: a singleton, keyed ones with deltas after their
|
|
* base, and a retired one.
|
|
*/
|
|
export async function archiveStore(options?: { readonly outbox?: boolean }): Promise<ChordStore> {
|
|
const { EditsV1, EditsV2, NotesV1, NotesV2 } = ArchiveTables;
|
|
const store = await memoryStore(options);
|
|
const fence = { admit: () => true, adopt: () => {} };
|
|
const edits = await tableAt(store, "app.store:edits", EditsV1);
|
|
const notes = await tableAt(store, "app.store:notes", NotesV1);
|
|
const staged: StagedWrite[] = [];
|
|
const editChanges = new TableChanges(edits, staged);
|
|
for (let i = 0; i < 150; i++) {
|
|
editChanges.put({
|
|
id: `e${String(i).padStart(3, "0")}`,
|
|
path: `p/${i % 4}`,
|
|
at: i,
|
|
...(i % 2 === 0 ? { note: `n${i}` } : {}),
|
|
...(i % 3 === 0 ? { meta: { a: i } } : {}),
|
|
});
|
|
}
|
|
const noteChanges = new TableChanges(notes, staged);
|
|
for (let i = 0; i < 150; i++) noteChanges.put({ id: i, text: `t${i}` });
|
|
await store.commit(staged, fence, BACKGROUND_CONTEXT);
|
|
await prepareTables([{ definition: EditsV2, table: "app.store:edits" }], store, fence, "app", BACKGROUND_CONTEXT);
|
|
await prepareTables(
|
|
[{ definition: NotesV2, table: "app.store:notes" }],
|
|
store,
|
|
fence,
|
|
"app",
|
|
BACKGROUND_CONTEXT,
|
|
).catch(() => {});
|
|
const address = { namespace: "chord", owner: "app.store", kind: "memo" };
|
|
const base = (value: JsonValue) => ({ version: 1, fingerprint: "f1", value });
|
|
let ids: readonly number[] = [];
|
|
const created = {
|
|
admit: () => true,
|
|
adopt: (_: number, created: readonly number[]) => {
|
|
ids = created;
|
|
},
|
|
};
|
|
await store.commit(
|
|
["", "a", "b", "gone"].map((key) =>
|
|
createDocument({ ...address, key }, base({ title: key }), JSON.stringify({ title: key })),
|
|
),
|
|
created,
|
|
BACKGROUND_CONTEXT,
|
|
);
|
|
const [, a, b, gone] = ids;
|
|
const delta = (title: string) => ({ kind: "delta", version: 1, ops: [["s", ["title"], title]] }) as const;
|
|
await store.commit(
|
|
[changeDocument(a!, 1, delta("a2"), JSON.stringify(delta("a2").ops)), retireDocument(gone!, 1)],
|
|
fence,
|
|
BACKGROUND_CONTEXT,
|
|
);
|
|
await store.commit([changeDocument(a!, 2, delta("a3"), JSON.stringify(delta("a3").ops))], fence, BACKGROUND_CONTEXT);
|
|
await store.commit([changeDocument(b!, 1, delta("b2"), JSON.stringify(delta("b2").ops))], fence, BACKGROUND_CONTEXT);
|
|
return store;
|
|
}
|
|
|
|
/** `definition` prepared as `table` in `store`, and a handle to it. */
|
|
async function tableAt(store: ChordStore, table: string, definition: TableDefinition): Promise<TableHandle> {
|
|
await prepareTables(
|
|
[{ definition, table }],
|
|
store,
|
|
{ admit: () => true, adopt: () => {} },
|
|
"app",
|
|
BACKGROUND_CONTEXT,
|
|
);
|
|
return new TableHandle(definition, table, store, tableOwner());
|
|
}
|
|
|
|
/** Every part of `store`'s export. */
|
|
export async function exported(store: Store): Promise<ArchivePart[]> {
|
|
const parts: ArchivePart[] = [];
|
|
for await (const part of store.export(BACKGROUND_CONTEXT)) parts.push(part);
|
|
return parts;
|
|
}
|