pi-mono/packages/chord/test/store/import.test.ts
Mario Zechner e9c0ff4d84 feat(chord): Chord v2 is the package's root
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
2026-11-02 22:36:55 +00:00

496 lines
17 KiB
TypeScript

// Tests of `importStore` (slice 6a §13, rules 12, 24, 25): round trips (old row versions, a migrating table,
// uncheckpointed deltas), the store it returns, every refusal of the archive's order and content, the rollback, and an
// input that stalls cancelled by `ctx`.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context, withCancel } from "../../src/context/index.ts";
import { nul, obj, opt, str } from "../../src/contracts/index.ts";
import type { JsonValue } from "../../src/json.ts";
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
import type { ArchivePart } from "../../src/store/archive.ts";
import { sqliteBackend } from "../../src/store/backend.ts";
import { findDocument, loadDocument } from "../../src/store/engine.ts";
import { importStore } from "../../src/store/import.ts";
import { json } from "../../src/store/json-column.ts";
import type { StagedWrite } from "../../src/store/step.ts";
import type { ChordStore } from "../../src/store/store.ts";
import { defineTable } from "../../src/store/table.ts";
import { TableChanges } from "../../src/store/table-changes.ts";
import { evolveTable } from "../../src/store/table-evolve.ts";
import { TableHandle } from "../../src/store/table-handle.ts";
import { prepareTables } from "../../src/store/table-migration.ts";
import { ArchiveTables, archiveStore, exported, memoryStore, rejection, tableIn, tableOwner } from "../support.ts";
const stores: ChordStore[] = [];
afterEach(async () => {
for (const store of stores.splice(0)) await store.close(context);
});
/** `parts` imported into a fresh `:memory:` backend. */
async function imported(parts: Iterable<unknown> | AsyncIterable<unknown>, outbox?: boolean): Promise<ChordStore> {
const backend = sqliteBackend(await openNodeSqliteDatabase(":memory:"));
const store = (await importStore(backend, parts, context, { outbox })) as ChordStore;
stores.push(store);
return store;
}
/** The archive of `archiveStore`. */
async function archive(): Promise<ArchivePart[]> {
const source = await archiveStore();
stores.push(source);
return exported(source);
}
describe("round trips", () => {
it("an imported archive exports the same parts: lossless, at the manifest's lastSeq and next document id", async () => {
const parts = await archive();
const store = await imported(parts);
expect(await exported(store)).toEqual(parts);
const manifest = parts[0] as Extract<ArchivePart, { readonly kind: "manifest" }>;
expect([store.lastSeq, store.nextId]).toEqual([manifest.lastSeq, manifest.nextId]);
});
it("the store keeps its ids, seqs and folded documents; the outbox, when asked, starts after lastSeq", async () => {
const parts = await archive();
const store = await imported(parts, true);
expect(await store.outbox!.read(store.lastSeq, 10, context)).toEqual([]);
expect(await rejection(store.outbox!.read(0, 10, context))).toMatchObject({ code: "invalid" });
const record = await store.read(
(executor) => findDocument(executor, { namespace: "chord", owner: "app.store", kind: "memo", key: "a" }),
context,
);
const original = parts.find((part) => part.kind === "document.create" && part.record.key === "a");
expect(record).toEqual((original as Extract<ArchivePart, { readonly kind: "document.create" }>).record);
expect((await store.read((executor) => loadDocument(executor, record!), context)).value).toEqual({ title: "a3" });
});
it("a migrating table resumes on the imported store; rows written at version 1 read as the table's", async () => {
const store = await imported(await archive());
const { EditsV2, NotesV2 } = ArchiveTables;
const notes = "app.store:notes";
const fence = { admit: () => true, adopt: () => {} };
await expect(
prepareTables([{ definition: NotesV2, table: notes }], store, fence, "app", context),
).rejects.toThrow("crash");
const edits = new TableHandle(EditsV2, "app.store:edits", store, tableOwner());
expect(await edits.get({ id: "e003" }, context)).toEqual({
id: "e003",
path: "p/3",
at: 3,
meta: { a: 3 },
done: false,
});
});
});
describe("json(...) cells holding null", () => {
it("a JSON null round-trips, required and optional, put and updated; an absent optional cell stays absent", async () => {
const source = await memoryStore();
stores.push(source);
const Cells = defineTable("cells", {
version: 1,
columns: { id: str(), data: json(nul(), { version: 1 }), more: opt(json(nul(), { version: 1 })) },
key: ["id"],
});
const handle = await tableIn(source, Cells);
const staged: StagedWrite[] = [];
const changes = new TableChanges(handle, staged);
changes.put({ id: "a", data: null, more: null });
changes.put({ id: "b", data: null });
changes.put({ id: "c", data: null, more: null });
changes.update({ id: "c" }, { more: undefined });
await source.commit(staged, { admit: () => true, adopt: () => {} }, context);
const rows = [
{ id: "a", data: null, more: null },
{ id: "b", data: null },
{ id: "c", data: null },
];
for (const row of rows) expect(await handle.get({ id: row.id }, context)).toEqual(row);
const parts = await exported(source);
const store = await imported(parts);
expect(await exported(store)).toEqual(parts);
const copy = new TableHandle(Cells, handle.sql.table, store, tableOwner());
for (const row of rows) expect(await copy.get({ id: row.id }, context)).toEqual(row);
});
it("a JSON null at an older cell version round-trips with its version and migrates on access in the copy", async () => {
const source = await memoryStore();
stores.push(source);
const V1 = defineTable("cells", {
version: 1,
columns: { id: str(), data: json(nul(), { version: 1 }) },
key: ["id"],
});
const old = await tableIn(source, V1);
const staged: StagedWrite[] = [];
new TableChanges(old, staged).put({ id: "a", data: null });
await source.commit(staged, { admit: () => true, adopt: () => {} }, context);
const V2 = evolveTable(V1, (t) =>
t.json("data", json(obj({ was: nul() }), { version: 2, migrate: (value) => ({ was: value as null }) })),
);
const handle = await tableIn(source, V2);
expect(await handle.get({ id: "a" }, context)).toEqual({ id: "a", data: { was: null } });
const parts = await exported(source);
expect(parts.find((part) => (part as ArchivePart).kind === "put")).toMatchObject({
row: { id: "a", data: null },
columns: { data: 1 },
});
const store = await imported(parts);
expect(await exported(store)).toEqual(parts);
const copy = new TableHandle(V2, handle.sql.table, store, tableOwner());
expect(await copy.get({ id: "a" }, context)).toEqual({ id: "a", data: { was: null } });
});
});
/** The archive with `edit` applied to a copy of its parts. */
async function edited(edit: (parts: ArchivePart[]) => unknown[]): Promise<unknown[]> {
return edit(structuredClone(await archive()));
}
/** The index of the first part of `kind` (and table suffix). */
function first(parts: readonly ArchivePart[], kind: ArchivePart["kind"], table?: string): number {
return parts.findIndex(
(part) => part.kind === kind && (table === undefined || ("table" in part && part.table.endsWith(table))),
);
}
interface Mutable {
[field: string]: JsonValue | Mutable;
}
/** Part `index` as a writable object. */
/** The positions of every part of `kind`. */
function all(parts: readonly ArchivePart[], kind: ArchivePart["kind"]): number[] {
return parts.flatMap((part, index) => (part.kind === kind ? [index] : []));
}
function at(parts: unknown[], index: number): Mutable {
return parts[index] as Mutable;
}
/** The content of the revision part at `index`, writable. */
function revisionContent(parts: unknown[], index: number): Mutable {
return at(parts, index).content as Mutable;
}
describe("refusals", () => {
it.each<[string, (parts: ArchivePart[]) => unknown[], string]>([
[
"a part outside the schema",
(parts) => [...parts.slice(0, 1), { kind: "put" }, ...parts.slice(1)],
"not an archive part",
],
["no manifest first", (parts) => parts.slice(1), "the manifest comes first"],
["a second manifest", (parts) => [parts[0], ...parts], "the manifest comes first, once"],
[
"another format",
(parts) => {
at(parts, 0).format = 2;
return parts;
},
"format 2 is not 1",
],
[
"a newer store schema",
(parts) => {
at(parts, 0).schema = 9;
return parts;
},
"schema 9 is newer",
],
[
"a lastSeq of 2^53 (a later seq would repeat)",
(parts) => {
at(parts, 0).lastSeq = 2 ** 53;
return parts;
},
"part 0: not an archive part",
],
[
"a document revision beyond 2^53 - 1",
(parts) => {
const record = at(parts, first(parts, "document.create")).record as Mutable;
record.revision = 2 ** 53;
return parts;
},
"not an archive part",
],
["a part after the end", (parts) => [...parts, { kind: "end", writes: 0 }], "after the end"],
["no end", (parts) => parts.slice(0, -1), "has no end part"],
[
"a wrong end count",
(parts) => {
at(parts, parts.length - 1).writes = 1;
return parts;
},
"counts 1 writes",
],
[
"a put before its table",
(parts) => {
const put = first(parts, "put", ":notes");
return [...parts.slice(0, first(parts, "table")), parts[put], ...parts.slice(first(parts, "table"))];
},
"a put follows its table's table write",
],
[
"a put after its table's indexes",
(parts) => {
const index = first(parts, "index");
return [...parts.slice(0, index + 1), parts[index - 1], ...parts.slice(index + 1)];
},
"a put follows its table's table write, before its indexes",
],
[
"a table twice",
(parts) => {
const table = first(parts, "table", ":notes");
return [...parts.slice(0, table), parts[first(parts, "table")], ...parts.slice(table)];
},
"occurs twice",
],
[
"a table after the documents",
(parts) => {
const table = first(parts, "table", ":notes");
const documents = first(parts, "document.create");
return [
...parts.slice(0, table),
...parts.slice(documents, -1),
...parts.slice(table, documents),
parts.at(-1),
];
},
"tables come before the documents",
],
[
"a table name that is not owner:table",
(parts) => {
at(parts, first(parts, "table")).table = "edits";
return parts;
},
"not owner:table",
],
[
"a column name breaking the rules",
(parts) => {
const table = at(parts, first(parts, "table"));
table.columns = { ...(table.columns as Mutable), "bad name": { type: "str", optional: true } };
return parts;
},
"not a column name",
],
[
"a row with an unknown column",
(parts) => {
(at(parts, first(parts, "put")).row as Mutable).extra = 1;
return parts;
},
'unknown column "extra"',
],
[
"a row without a non-opt column",
(parts) => {
delete (at(parts, first(parts, "put")).row as Mutable).path;
return parts;
},
'column "path" is missing',
],
[
"a value of another type",
(parts) => {
(at(parts, first(parts, "put")).row as Mutable).at = "x";
return parts;
},
"is not a int value",
],
[
"a json(...) cell newer than its column",
(parts) => {
(at(parts, first(parts, "put")).columns as Mutable).meta = 5;
return parts;
},
"no readable version",
],
[
"a row newer than its table",
(parts) => {
at(parts, first(parts, "put")).version = 7;
return parts;
},
"version 7 is newer",
],
[
"an id not below the manifest's next id",
(parts) => {
(at(parts, first(parts, "document.create")).record as Mutable).id = 10_000;
return parts;
},
"is not below",
],
[
"an id twice",
(parts) => {
const [one, two] = all(parts, "document.create");
(at(parts, two!).record as Mutable).id = (at(parts, one!).record as Mutable).id!;
return parts;
},
"occurs twice",
],
[
"a record created after lastSeq",
(parts) => {
(at(parts, first(parts, "document.create")).record as Mutable).createdAt = 10_000;
return parts;
},
"a lifetime outside the manifest's lastSeq",
],
[
"a base at another version than its record's",
(parts) => {
revisionContent(parts, first(parts, "document.revision")).version = 2;
return parts;
},
"has no base at its version",
],
[
"a delta without a base at its version",
(parts) => {
const delta = parts.findIndex((part) => part.kind === "document.revision" && part.content.kind === "delta");
revisionContent(parts, delta).version = 2;
return parts;
},
"a delta without a base at its version",
],
[
"a revision out of seq order",
(parts) => {
const delta = parts.findIndex((part) => part.kind === "document.revision" && part.content.kind === "delta");
at(parts, delta).seq = 1;
return parts;
},
"out of seq order",
],
[
"a revision of another record",
(parts) => {
at(parts, first(parts, "document.revision")).id = 9_999;
return parts;
},
"follows its record's create",
],
[
"a live record without revisions",
(parts) => parts.filter((_, index) => index !== first(parts, "document.revision")),
"has no base at its version",
],
[
"revisions of a retired none record",
(parts) => {
const record = at(parts, first(parts, "document.create")).record as Mutable;
record.retiredAt = record.createdAt!;
return parts;
},
"keeps no revisions",
],
[
"a retired rewindable record without its history",
(parts) => {
const record = at(parts, first(parts, "document.create")).record as Mutable;
record.history = "rewindable";
record.retiredAt = record.createdAt!;
const id = record.id;
return parts.filter((part) => part.kind !== "document.revision" || part.id !== id);
},
"has no base at its version",
],
[
"two live records at one address",
(parts) => {
const [one, two] = all(parts, "document.create");
(at(parts, two!).record as Mutable).key = (at(parts, one!).record as Mutable).key!;
return parts;
},
"a second record at one address for an overlapping lifetime",
],
[
"a record's meta with a NUL",
(parts) => {
(at(parts, first(parts, "document.create")).record as Mutable).meta = "a\u0000b";
return parts;
},
"text may not contain NUL",
],
[
"a record's meta with a lone surrogate",
(parts) => {
(at(parts, first(parts, "document.create")).record as Mutable).meta = "a\uD800";
return parts;
},
"the meta is not well-formed text",
],
])("%s", async (_, edit, message) => {
const parts = await edited(edit);
expect(await rejection(imported(parts))).toMatchObject({
code: "invalid",
reason: "args",
message: expect.stringContaining(message),
});
});
it("a non-empty backend is refused; a refused import closes its backend and leaves it as it was", async () => {
const directory = await mkdtemp(join(tmpdir(), "chord-import-"));
try {
const path = join(directory, "store.db");
const parts = await archive();
const broken = [...parts.slice(0, -1), { kind: "end", writes: 0 }];
expect(
await rejection(importStore(sqliteBackend(await openNodeSqliteDatabase(path)), broken, context)),
).toMatchObject({
code: "invalid",
});
const db = await openNodeSqliteDatabase(path);
expect(await db.all("SELECT name FROM sqlite_master")).toEqual([]);
await db.close();
const store = await importStore(sqliteBackend(await openNodeSqliteDatabase(path)), parts, context);
await store.close(context);
expect(
await rejection(importStore(sqliteBackend(await openNodeSqliteDatabase(path)), parts, context)),
).toMatchObject({
message: "import: the backend holds a store already",
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("an input that stalls is cancelled by ctx: the import rejects aborted and the input is closed", async () => {
const parts = await archive();
let closed = false;
const stalled: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let index = 0;
return {
next: () =>
index < 3
? Promise.resolve({ done: false, value: parts[index++] })
: new Promise<IteratorResult<unknown>>(() => {}),
return: async () => {
closed = true;
return { done: true, value: undefined };
},
};
},
};
const { context: cancellable, cancel } = withCancel(context);
const backend = sqliteBackend(await openNodeSqliteDatabase(":memory:"));
const importing = importStore(backend, stalled, cancellable);
await new Promise((resolve) => setTimeout(resolve, 10));
cancel(new Error("stop"));
expect(await rejection(importing)).toMatchObject({ code: "aborted" });
expect(closed).toBe(true);
});
});