feat: abstract filestystem

This commit is contained in:
Cristina Poncela Cubeiro 2026-11-04 16:46:23 +01:00
parent 533a4e18a8
commit 1a93bad3ed
15 changed files with 331 additions and 251 deletions

View file

@ -30,7 +30,6 @@
"@earendil-works/chord/tui": ["../../packages/chord/src/tui/index.ts"], "@earendil-works/chord/tui": ["../../packages/chord/src/tui/index.ts"],
"@earendil-works/chord/tui/launcher": ["../../packages/chord/src/node/tui/index.ts"], "@earendil-works/chord/tui/launcher": ["../../packages/chord/src/node/tui/index.ts"],
"@earendil-works/chord/sync": ["../../packages/chord/src/sync/index.ts"], "@earendil-works/chord/sync": ["../../packages/chord/src/sync/index.ts"],
"@earendil-works/chord/sync/node": ["../../packages/chord/src/sync/node/index.ts"],
"@earendil-works/pi-telemetry": ["../../packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry": ["../../packages/telemetry/src/index.ts"],
"@earendil-works/pi-telemetry/testing": ["../../packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-telemetry/testing": ["../../packages/telemetry/src/testing/index.ts"],
"@earendil-works/pi-telemetry/render": ["../../packages/telemetry/src/render.ts"], "@earendil-works/pi-telemetry/render": ["../../packages/telemetry/src/render.ts"],

View file

@ -9,7 +9,7 @@
### Added ### Added
- The sync layer (experimental): `@earendil-works/chord/sync` moves a store between devices through a remote, from the store's outbox (`openStore(backend, ctx, { outbox: true })`). Every device continues a store as its own: `uploadSession()` uploads the outbox commits after the last sequence the copy shares with the remote as one gzip batch into a slot the remote creates once per start sequence, trims the outbox through what the remote acknowledged, and reports a copy as `diverged` when another device's upload created that slot first, or `behind` when its head fell below what it had uploaded. A store whose outbox does not reach back to its first commit starts with an archive slot (`store.export()`). `pullSession()` catches a copy up through `applyBatch()`, resumes an interrupted pull, or restores into an empty backend (`importStore()` for an archive); `foldSession()` folds the history into a snapshot archive, keeping folded slots taken. `FlushScheduler` decides when to upload. `@earendil-works/chord/sync/node` has `FileSystemSyncRemote`, a remote in a directory, and `FileSyncStateStore`, sync state in a directory, for development and tests. - The sync layer (experimental): `@earendil-works/chord/sync` moves a store between devices through a remote, from the store's outbox (`openStore(backend, ctx, { outbox: true })`). Every device continues a store as its own: `uploadSession()` uploads the outbox commits after the last sequence the copy shares with the remote as one gzip batch into a slot the remote creates once per start sequence, trims the outbox through what the remote acknowledged, and reports a copy as `diverged` when another device's upload created that slot first, or `behind` when its head fell below what it had uploaded. A store whose outbox does not reach back to its first commit starts with an archive slot (`store.export()`). `pullSession()` catches a copy up through `applyBatch()`, resumes an interrupted pull, or restores into an empty backend (`importStore()` for an archive); `foldSession()` folds the history into a snapshot archive, keeping folded slots taken. `FlushScheduler` decides when to upload. `FileSystemSyncRemote`, a remote in a directory, and `FileSyncStateStore`, sync state in a directory, are for development and tests; they take their file operations as a `SyncFileSystem` (Pi Durable's environments fit it as they are).
- Chord v2's Node components and client surfaces are exported: `@earendil-works/chord/http`, `/workers`, `/plugins`, `/plugins/runtime` and `/app`; `/surfaces` (the web and terminal surfaces' server side), `/web`, `/react`, `/tui` and `/tui/launcher`. - Chord v2's Node components and client surfaces are exported: `@earendil-works/chord/http`, `/workers`, `/plugins`, `/plugins/runtime` and `/app`; `/surfaces` (the web and terminal surfaces' server side), `/web`, `/react`, `/tui` and `/tui/launcher`.
- Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `<name>.<kind>.<ext>` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/plugin-authoring.md`. - Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `<name>.<kind>.<ext>` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/plugin-authoring.md`.
- Chord v2 gains the app kit (slice 13, `@earendil-works/chord/app`): `startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)` lays out the data directory (`server/store.sqlite`, `workers/<kind>/<address>/`, `plugins/`, `logs/`, `owner-token`; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; `startWorker({ api, kinds, busy?, onError })` is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects `AppInfo` and `WorkerInfo` serve process values to plugins. Sessions (`Sessions`: `issue`, `verify`, `revoke`, `list`; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most `limits.ephemeralSessions`) authenticate a signed `HttpOnly`, `SameSite=Strict` cookie or `Authorization: Bearer`; a one-time owner link per start (`app.ownerLink()`, the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (`POST /_chord/pair`, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. `Files` per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most `limits.uploads` uploads under way. `app.exportData(address, path, ctx)` and `app.importData(archive, address, ctx)` move the data a worker created (its store and files) as a tar without stopping anything: a running worker writes its own store at one consistent point, else the server reads it under the worker's process lock (import refusing an existing address, another app's archive, every path leaving the target, and archives past `limits.importBytes` or `limits.importEntries`). `app.applyChanges(ctx)` and `app.plugins.install(source, ctx)`. Optional plugins as exported facets: `defineUsers(profile)`, `inviteFacet()`, `ephemeralLoginFacet()`. - Chord v2 gains the app kit (slice 13, `@earendil-works/chord/app`): `startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)` lays out the data directory (`server/store.sqlite`, `workers/<kind>/<address>/`, `plugins/`, `logs/`, `owner-token`; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; `startWorker({ api, kinds, busy?, onError })` is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects `AppInfo` and `WorkerInfo` serve process values to plugins. Sessions (`Sessions`: `issue`, `verify`, `revoke`, `list`; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most `limits.ephemeralSessions`) authenticate a signed `HttpOnly`, `SameSite=Strict` cookie or `Authorization: Bearer`; a one-time owner link per start (`app.ownerLink()`, the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (`POST /_chord/pair`, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. `Files` per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most `limits.uploads` uploads under way. `app.exportData(address, path, ctx)` and `app.importData(archive, address, ctx)` move the data a worker created (its store and files) as a tar without stopping anything: a running worker writes its own store at one consistent point, else the server reads it under the worker's process lock (import refusing an existing address, another app's archive, every path leaving the target, and archives past `limits.importBytes` or `limits.importEntries`). `app.applyChanges(ctx)` and `app.plugins.install(source, ctx)`. Optional plugins as exported facets: `defineUsers(profile)`, `inviteFacet()`, `ephemeralLoginFacet()`.

View file

@ -35,8 +35,7 @@ The design, the rule book for all of this, is [docs/design.md](docs/design.md).
| `@earendil-works/chord/sqlite` | Portable `SqliteDatabase`, `SqliteExecutor`, and `SqliteValue`, atomic `applySqliteMigrations(database, migrations, { table })`, and `validateSqliteSchemaPrefix`. The metadata table must be a valid, non-reserved SQLite identifier; migration versions start at 1 and are contiguous. | Any SQLite adapter | | `@earendil-works/chord/sqlite` | Portable `SqliteDatabase`, `SqliteExecutor`, and `SqliteValue`, atomic `applySqliteMigrations(database, migrations, { table })`, and `validateSqliteSchemaPrefix`. The metadata table must be a valid, non-reserved SQLite identifier; migration versions start at 1 and are contiguous. | Any SQLite adapter |
| `@earendil-works/chord/sqlite/node` | `NodeSqliteDatabase`, `openNodeSqliteDatabase`, and `NodeSqliteDatabaseOptions`. | Node | | `@earendil-works/chord/sqlite/node` | `NodeSqliteDatabase`, `openNodeSqliteDatabase`, and `NodeSqliteDatabaseOptions`. | Node |
| `@earendil-works/chord/sqlite/cloudflare` | `DurableObjectSqliteDatabase` and the structural `DurableObjectSqliteStorage` binding type. | Cloudflare Durable Objects | | `@earendil-works/chord/sqlite/cloudflare` | `DurableObjectSqliteDatabase` and the structural `DurableObjectSqliteStorage` binding type. | Cloudflare Durable Objects |
| `@earendil-works/chord/sync` | Sync (experimental): a store's outbox commits uploaded to a remote into slots created once (`uploadSession`), applied on another copy (`pullSession`), folded into snapshots (`foldSession`); `FlushScheduler`; the remote and sync-state interfaces. | Node and browsers | | `@earendil-works/chord/sync` | Sync (experimental): a store's outbox commits uploaded to a remote into slots created once (`uploadSession`), applied on another copy (`pullSession`), folded into snapshots (`foldSession`); `FlushScheduler`; the remote and sync-state interfaces, and directory-backed ones over a `SyncFileSystem` for development and tests. | Node and browsers |
| `@earendil-works/chord/sync/node` | `FileSystemSyncRemote` and `FileSyncStateStore`: a remote and sync state in a directory, for development and tests. | Node |
`esbuild` (for `/plugins` and `/surfaces`), `ws` (for `/http` and `/tui/launcher`) and `react` (for `/react`) are optional peer dependencies, imported only there; `/app` uses all but `react`. `esbuild` (for `/plugins` and `/surfaces`), `ws` (for `/http` and `/tui/launcher`) and `react` (for `/react`) are optional peer dependencies, imported only there; `/app` uses all but `react`.

View file

@ -111,11 +111,6 @@
"types": "./dist/sync/index.d.ts", "types": "./dist/sync/index.d.ts",
"import": "./dist/sync/index.js" "import": "./dist/sync/index.js"
}, },
"./sync/node": {
"source": "./src/sync/node/index.ts",
"types": "./dist/sync/node/index.d.ts",
"import": "./dist/sync/node/index.js"
},
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"sideEffects": false, "sideEffects": false,

View file

@ -0,0 +1,37 @@
// The file operations the directory-backed sync pieces need, as an interface: any environment that has them (Node,
// a container, a remote host) can back a sync remote or the host's sync state. Shaped as Pi Durable's `FileSystem`
// (`@earendil-works/pi-durable/env`), so its environments pass as they are.
import type { Context } from "../context/index.ts";
/** An operation's outcome: its value, or the error it failed with. */
export type FileResult<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: Error };
/** A directory entry. */
export type SyncFileInfo = { readonly name: string; readonly kind: "file" | "directory" | "symlink" };
/** File operations for the directory-backed sync pieces. A missing file or directory fails with `code: "not_found"`. */
export interface SyncFileSystem {
joinPath(parts: string[], context: Context): Promise<FileResult<string>>;
readTextFile(path: string, context: Context): Promise<FileResult<string>>;
readBinaryFile(path: string, context: Context): Promise<FileResult<Uint8Array>>;
writeFile(path: string, content: string | Uint8Array, context: Context): Promise<FileResult<void>>;
renameFile(sourcePath: string, destinationPath: string, context: Context): Promise<FileResult<void>>;
listDir(path: string, context: Context): Promise<FileResult<SyncFileInfo[]>>;
createDir(path: string, options: { recursive?: boolean } | undefined, context: Context): Promise<FileResult<void>>;
remove(
path: string,
options: { recursive?: boolean; force?: boolean } | undefined,
context: Context,
): Promise<FileResult<void>>;
}
/** Whether `result` failed because the file or directory is missing. */
export function isNotFound(result: FileResult<unknown>): boolean {
return !result.ok && (result.error as { readonly code?: unknown }).code === "not_found";
}
export function unwrap<T>(result: FileResult<T>): T {
if (!result.ok) throw result.error;
return result.value;
}

View file

@ -0,0 +1,193 @@
import type { Context } from "../context/index.ts";
import { isNotFound, type SyncFileSystem, unwrap } from "./files.ts";
import {
type BatchInfo,
type JsonObject,
type PutBatchResult,
type RemoteListing,
remoteHead,
type Seq,
type SessionChanges,
type SessionSyncMaintenance,
} from "./remote.ts";
const pad = (value: number): string => String(value).padStart(16, "0");
const SLOT_NAME = /^(\d{16})\.json$/;
const SNAPSHOT_NAME = /^(\d{16})\.batch$/;
const SESSION_ID = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
/** A session ID names a file or directory: refuse anything that could leave the root. */
export function checkSessionId(sessionId: string): string {
if (!SESSION_ID.test(sessionId)) throw new Error(`Invalid session ID for a sync directory: ${sessionId}`);
return sessionId;
}
/**
* A remote store in a directory, laid out as the real remote is: per session, one slot per batch start, holding where
* the batch ends and which device created it, and snapshots named by their end. As the real remote, it creates a slot
* only where the history ends. Creating a slot is check-then-write,
* without the atomicity of the real remote across processes: for development and tests, with one process writing.
*
* ```text
* {root}/{session}/slots/{after}.json { after, to, device, archive?, folded? }, written once
* {root}/{session}/batches/{after}.batch the commits (or an archive), until folded
* {root}/{session}/snapshots/{to}.batch a store archive
* {root}/{session}/meta.json the host's description, last write wins
* ```
*/
export class FileSystemSyncRemote implements SessionSyncMaintenance {
readonly #fs: SyncFileSystem;
readonly #root: string;
constructor(fs: SyncFileSystem, root: string) {
this.#fs = fs;
this.#root = root;
}
async putBatch(
sessionId: string,
after: Seq,
to: Seq,
device: string,
body: Uint8Array,
context: Context,
options: { readonly archive?: boolean } = {},
): Promise<PutBatchResult> {
const existing = await this.#slot(sessionId, after, context);
if (existing !== undefined) return { type: "exists", to: existing.to, device: existing.device };
const head = remoteHead(await this.list(sessionId, context));
if (after !== head) return { type: "not_head", head };
await this.#write(sessionId, ["batches"], `${pad(after)}.batch`, body, context);
// The slot is written last: it is what makes the batch exist.
const slot: BatchInfo = { after, to, device, ...(options.archive === true ? { archive: true as const } : {}) };
await this.#write(
sessionId,
["slots"],
`${pad(after)}.json`,
new TextEncoder().encode(JSON.stringify(slot)),
context,
);
return { type: "created" };
}
async getBatch(sessionId: string, after: Seq, context: Context): Promise<Uint8Array | undefined> {
const slot = await this.#slot(sessionId, after, context);
if (slot === undefined || slot.folded) return undefined;
return this.#read(context, sessionId, "batches", `${pad(after)}.batch`);
}
getSnapshot(sessionId: string, to: Seq, context: Context): Promise<Uint8Array | undefined> {
return this.#read(context, sessionId, "snapshots", `${pad(to)}.batch`);
}
async putSnapshot(sessionId: string, to: Seq, body: Uint8Array, context: Context): Promise<void> {
await this.#write(sessionId, ["snapshots"], `${pad(to)}.batch`, body, context);
}
async foldBatches(sessionId: string, afters: readonly Seq[], context: Context): Promise<void> {
for (const after of afters) {
const slot = await this.#slot(sessionId, after, context);
if (slot === undefined || slot.folded) continue;
const folded: BatchInfo = { ...slot, folded: true };
await this.#write(
sessionId,
["slots"],
`${pad(after)}.json`,
new TextEncoder().encode(JSON.stringify(folded)),
context,
);
const body = await this.#path(context, sessionId, "batches", `${pad(after)}.batch`);
unwrap(await this.#fs.remove(body, { force: true }, context));
}
}
async deleteSnapshots(sessionId: string, snapshots: readonly Seq[], context: Context): Promise<void> {
for (const to of snapshots) {
const path = await this.#path(context, sessionId, "snapshots", `${pad(to)}.batch`);
unwrap(await this.#fs.remove(path, { force: true }, context));
}
}
async list(sessionId: string, context: Context): Promise<RemoteListing> {
const batches: BatchInfo[] = [];
for (const name of await this.#names(context, sessionId, "slots")) {
const match = SLOT_NAME.exec(name);
if (match === null) continue;
const slot = await this.#slot(sessionId, Number(match[1]), context);
if (slot !== undefined) batches.push(slot);
}
const snapshots: Seq[] = [];
for (const name of await this.#names(context, sessionId, "snapshots")) {
const match = SNAPSHOT_NAME.exec(name);
if (match !== null) snapshots.push(Number(match[1]));
}
return { batches, snapshots };
}
/** Every session, every time: a directory keeps no change order. */
async sessions(_cursor: string | undefined, context: Context): Promise<SessionChanges> {
const result = await this.#fs.listDir(this.#root, context);
const names = isNotFound(result)
? []
: unwrap(result)
.filter((info) => info.kind === "directory" && SESSION_ID.test(info.name))
.map((info) => info.name)
.sort();
const sessions = [];
for (const id of names) {
sessions.push({
id,
head: remoteHead(await this.list(id, context)),
meta: await this.getMeta(id, context),
});
}
return { sessions, cursor: "", more: false };
}
async putMeta(sessionId: string, meta: JsonObject, context: Context): Promise<void> {
await this.#write(sessionId, [], "meta.json", new TextEncoder().encode(JSON.stringify(meta)), context);
}
async getMeta(sessionId: string, context: Context): Promise<JsonObject | undefined> {
const bytes = await this.#read(context, sessionId, "meta.json");
return bytes === undefined ? undefined : (JSON.parse(new TextDecoder().decode(bytes)) as JsonObject);
}
async #slot(sessionId: string, after: Seq, context: Context): Promise<BatchInfo | undefined> {
const bytes = await this.#read(context, sessionId, "slots", `${pad(after)}.json`);
return bytes === undefined ? undefined : (JSON.parse(new TextDecoder().decode(bytes)) as BatchInfo);
}
async #names(context: Context, sessionId: string, ...parts: string[]): Promise<string[]> {
const result = await this.#fs.listDir(await this.#path(context, sessionId, ...parts), context);
if (isNotFound(result)) return [];
return unwrap(result).map((info) => info.name);
}
async #read(context: Context, sessionId: string, ...parts: string[]): Promise<Uint8Array | undefined> {
const result = await this.#fs.readBinaryFile(await this.#path(context, sessionId, ...parts), context);
if (isNotFound(result)) return undefined;
return unwrap(result);
}
/** Write through a temporary file and a rename, so a reader never sees half a file. */
async #write(
sessionId: string,
directory: readonly string[],
name: string,
body: Uint8Array,
context: Context,
): Promise<void> {
unwrap(
await this.#fs.createDir(await this.#path(context, sessionId, ...directory), { recursive: true }, context),
);
const path = await this.#path(context, sessionId, ...directory, name);
const temporary = await this.#path(context, sessionId, ...directory, `${name}.tmp`);
unwrap(await this.#fs.writeFile(temporary, body, context));
unwrap(await this.#fs.renameFile(temporary, path, context));
}
async #path(context: Context, sessionId: string, ...parts: string[]): Promise<string> {
return unwrap(await this.#fs.joinPath([this.#root, checkSessionId(sessionId), ...parts], context));
}
}

View file

@ -1,5 +1,5 @@
// Session sync over a store's outbox (`@earendil-works/chord/sync`, experimental). Portable: the remote and the host's // Session sync over a store's outbox (`@earendil-works/chord/sync`, experimental). Portable: the remote and the host's
// sync state are interfaces; `@earendil-works/chord/sync/node` has directory-backed ones for development and tests. // sync state are interfaces, and the directory-backed ones take their file operations (`SyncFileSystem`).
export { decodeBatch, decodeSnapshot, encodeBatch, encodeSnapshot } from "./batch.ts"; export { decodeBatch, decodeSnapshot, encodeBatch, encodeSnapshot } from "./batch.ts";
export { export {
@ -17,6 +17,8 @@ export {
type UploadResult, type UploadResult,
uploadSession, uploadSession,
} from "./client.ts"; } from "./client.ts";
export type { FileResult, SyncFileInfo, SyncFileSystem } from "./files.ts";
export { checkSessionId, FileSystemSyncRemote } from "./filesystem.ts";
export { decodeLogLines, encodeLogLine, LOG_VERSION, LogFormatError } from "./lines.ts"; export { decodeLogLines, encodeLogLine, LOG_VERSION, LogFormatError } from "./lines.ts";
export { export {
type BatchInfo, type BatchInfo,
@ -34,3 +36,4 @@ export {
type SessionSyncMaintenance, type SessionSyncMaintenance,
type SessionSyncStore, type SessionSyncStore,
} from "./remote.ts"; } from "./remote.ts";
export { FileSyncStateStore } from "./state.ts";

View file

@ -1,5 +0,0 @@
// Directory-backed sync pieces for Node (`@earendil-works/chord/sync/node`): a remote and the host's sync state, for
// development and tests.
export { checkSessionId, FileSystemSyncRemote } from "./remote.ts";
export { FileSyncStateStore } from "./state.ts";

View file

@ -1,180 +0,0 @@
// A sync remote in a local directory, for development and tests (Node).
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Context } from "../../context/index.ts";
import {
type BatchInfo,
type JsonObject,
type PutBatchResult,
type RemoteListing,
remoteHead,
type Seq,
type SessionChanges,
type SessionSyncMaintenance,
} from "../remote.ts";
const pad = (value: number): string => String(value).padStart(16, "0");
const SLOT_NAME = /^(\d{16})\.json$/;
const SNAPSHOT_NAME = /^(\d{16})\.batch$/;
const SESSION_ID = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
/** A session ID names a file or directory: refuse anything that could leave the root. */
export function checkSessionId(sessionId: string): string {
if (!SESSION_ID.test(sessionId)) throw new Error(`Invalid session ID for a sync directory: ${sessionId}`);
return sessionId;
}
/** Whether `error` is a missing file or directory. */
export function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
}
/** Write through a temporary file and a rename, so a reader never sees half a file. */
export async function writeAtomically(directory: string, name: string, body: Uint8Array | string): Promise<void> {
await mkdir(directory, { recursive: true });
const temporary = join(directory, `${name}.tmp`);
await writeFile(temporary, body);
await rename(temporary, join(directory, name));
}
/**
* A remote store in a directory, laid out as the real remote is: per session, one slot per batch start, holding where
* the batch ends and which device created it, and snapshots named by their end. As the real remote, it creates a slot
* only where the history ends. Creating a slot is check-then-write, without the atomicity of the real remote across
* processes: for development and tests, with one process writing.
*
* ```text
* {root}/{session}/slots/{after}.json { after, to, device, archive?, folded? }, written once
* {root}/{session}/batches/{after}.batch the commits (or an archive), until folded
* {root}/{session}/snapshots/{to}.batch a store archive
* {root}/{session}/meta.json the host's description, last write wins
* ```
*/
export class FileSystemSyncRemote implements SessionSyncMaintenance {
readonly #root: string;
constructor(root: string) {
this.#root = root;
}
async putBatch(
sessionId: string,
after: Seq,
to: Seq,
device: string,
body: Uint8Array,
context: Context,
options: { readonly archive?: boolean } = {},
): Promise<PutBatchResult> {
const existing = await this.#slot(sessionId, after);
if (existing !== undefined) return { type: "exists", to: existing.to, device: existing.device };
const head = remoteHead(await this.list(sessionId, context));
if (after !== head) return { type: "not_head", head };
await writeAtomically(this.#path(sessionId, "batches"), `${pad(after)}.batch`, body);
// The slot is written last: it is what makes the batch exist.
const slot: BatchInfo = { after, to, device, ...(options.archive === true ? { archive: true as const } : {}) };
await writeAtomically(this.#path(sessionId, "slots"), `${pad(after)}.json`, JSON.stringify(slot));
return { type: "created" };
}
async getBatch(sessionId: string, after: Seq, _context: Context): Promise<Uint8Array | undefined> {
const slot = await this.#slot(sessionId, after);
if (slot === undefined || slot.folded) return undefined;
return this.#read(this.#path(sessionId, "batches", `${pad(after)}.batch`));
}
getSnapshot(sessionId: string, to: Seq, _context: Context): Promise<Uint8Array | undefined> {
return this.#read(this.#path(sessionId, "snapshots", `${pad(to)}.batch`));
}
async putSnapshot(sessionId: string, to: Seq, body: Uint8Array, _context: Context): Promise<void> {
await writeAtomically(this.#path(sessionId, "snapshots"), `${pad(to)}.batch`, body);
}
async foldBatches(sessionId: string, afters: readonly Seq[], _context: Context): Promise<void> {
for (const after of afters) {
const slot = await this.#slot(sessionId, after);
if (slot === undefined || slot.folded) continue;
const folded: BatchInfo = { ...slot, folded: true };
await writeAtomically(this.#path(sessionId, "slots"), `${pad(after)}.json`, JSON.stringify(folded));
await rm(this.#path(sessionId, "batches", `${pad(after)}.batch`), { force: true });
}
}
async deleteSnapshots(sessionId: string, snapshots: readonly Seq[], _context: Context): Promise<void> {
for (const to of snapshots) await rm(this.#path(sessionId, "snapshots", `${pad(to)}.batch`), { force: true });
}
async list(sessionId: string, _context: Context): Promise<RemoteListing> {
const batches: BatchInfo[] = [];
for (const name of await this.#names(this.#path(sessionId, "slots"))) {
const match = SLOT_NAME.exec(name);
if (match === null) continue;
const slot = await this.#slot(sessionId, Number(match[1]));
if (slot !== undefined) batches.push(slot);
}
const snapshots: Seq[] = [];
for (const name of await this.#names(this.#path(sessionId, "snapshots"))) {
const match = SNAPSHOT_NAME.exec(name);
if (match !== null) snapshots.push(Number(match[1]));
}
return { batches, snapshots };
}
/** Every session, every time: a directory keeps no change order. */
async sessions(_cursor: string | undefined, context: Context): Promise<SessionChanges> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(this.#root, { withFileTypes: true });
} catch (error) {
if (!isNotFound(error)) throw error;
entries = [];
}
const names = entries
.filter((entry) => entry.isDirectory() && SESSION_ID.test(entry.name))
.map((entry) => entry.name)
.sort();
const sessions = [];
for (const id of names) {
sessions.push({ id, head: remoteHead(await this.list(id, context)), meta: await this.getMeta(id, context) });
}
return { sessions, cursor: "", more: false };
}
async putMeta(sessionId: string, meta: JsonObject, _context: Context): Promise<void> {
await writeAtomically(this.#path(sessionId), "meta.json", JSON.stringify(meta));
}
async getMeta(sessionId: string, _context: Context): Promise<JsonObject | undefined> {
const bytes = await this.#read(this.#path(sessionId, "meta.json"));
return bytes === undefined ? undefined : (JSON.parse(new TextDecoder().decode(bytes)) as JsonObject);
}
async #slot(sessionId: string, after: Seq): Promise<BatchInfo | undefined> {
const bytes = await this.#read(this.#path(sessionId, "slots", `${pad(after)}.json`));
return bytes === undefined ? undefined : (JSON.parse(new TextDecoder().decode(bytes)) as BatchInfo);
}
async #names(directory: string): Promise<string[]> {
try {
return await readdir(directory);
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
}
async #read(path: string): Promise<Uint8Array | undefined> {
try {
return new Uint8Array(await readFile(path));
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
}
}
#path(sessionId: string, ...parts: string[]): string {
return join(this.#root, checkSessionId(sessionId), ...parts);
}
}

View file

@ -1,49 +0,0 @@
// Sync state in a local directory (Node).
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import type { Context } from "../../context/index.ts";
import type { SyncState, SyncStateStore } from "../client.ts";
import type { Seq } from "../remote.ts";
import { checkSessionId, isNotFound, writeAtomically } from "./remote.ts";
/**
* Sync state in a directory, one JSON file per session, replaced through a temporary file and a rename. Losing the
* directory loses nothing that matters: a copy without commits of its own takes the remote's head, and one with
* commits finds out by uploading.
*/
export class FileSyncStateStore implements SyncStateStore {
readonly #directory: string;
constructor(directory: string) {
this.#directory = directory;
}
async get(sessionId: string, _context: Context): Promise<SyncState | undefined> {
let text: string;
try {
text = await readFile(this.#path(sessionId), "utf8");
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
}
const stored = JSON.parse(text) as { syncedHead?: Seq; pulling?: Seq };
return {
syncedHead: stored.syncedHead,
...(stored.pulling === undefined ? {} : { pulling: stored.pulling }),
};
}
async set(sessionId: string, state: SyncState, _context: Context): Promise<void> {
await writeAtomically(this.#directory, `${checkSessionId(sessionId)}.json`, JSON.stringify(state));
}
/** Forget a session, as when its copy is renamed or deleted. */
async delete(sessionId: string, _context: Context): Promise<void> {
await rm(this.#path(sessionId), { force: true });
}
#path(sessionId: string): string {
return join(this.#directory, `${checkSessionId(sessionId)}.json`);
}
}

View file

@ -0,0 +1,47 @@
import type { Context } from "../context/index.ts";
import type { SyncState, SyncStateStore } from "./client.ts";
import { isNotFound, type SyncFileSystem, unwrap } from "./files.ts";
import { checkSessionId } from "./filesystem.ts";
import type { Seq } from "./remote.ts";
/**
* Sync state in a directory, one JSON file per session, replaced through a temporary file and a rename. Losing the
* directory loses nothing that matters: a copy without commits of its own takes the remote's head, and one with
* commits finds out by uploading.
*/
export class FileSyncStateStore implements SyncStateStore {
readonly #fs: SyncFileSystem;
readonly #directory: string;
constructor(fs: SyncFileSystem, directory: string) {
this.#fs = fs;
this.#directory = directory;
}
async get(sessionId: string, context: Context): Promise<SyncState | undefined> {
const result = await this.#fs.readTextFile(await this.#path(sessionId, ".json", context), context);
if (isNotFound(result)) return undefined;
const stored = JSON.parse(unwrap(result)) as { syncedHead?: Seq; pulling?: Seq };
return {
syncedHead: stored.syncedHead,
...(stored.pulling === undefined ? {} : { pulling: stored.pulling }),
};
}
async set(sessionId: string, state: SyncState, context: Context): Promise<void> {
unwrap(await this.#fs.createDir(this.#directory, { recursive: true }, context));
const path = await this.#path(sessionId, ".json", context);
const temporary = await this.#path(sessionId, ".json.tmp", context);
unwrap(await this.#fs.writeFile(temporary, JSON.stringify(state), context));
unwrap(await this.#fs.renameFile(temporary, path, context));
}
/** Forget a session, as when its copy is renamed or deleted. */
async delete(sessionId: string, context: Context): Promise<void> {
unwrap(await this.#fs.remove(await this.#path(sessionId, ".json", context), { force: true }, context));
}
async #path(sessionId: string, suffix: string, context: Context): Promise<string> {
return unwrap(await this.#fs.joinPath([this.#directory, `${checkSessionId(sessionId)}${suffix}`], context));
}
}

View file

@ -0,0 +1,43 @@
// `SyncFileSystem` over Node's file system, for the sync tests (a host passes its own environment, such as Pi Durable's
// `NodeExecutionEnv`).
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FileResult, SyncFileInfo, SyncFileSystem } from "../../src/sync/index.ts";
/** An error with a `code`: `not_found` for a missing file or directory, as `SyncFileSystem` reports it. */
class FileError extends Error {
readonly code: string;
constructor(code: string, cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.code = code;
}
}
async function attempt<T>(operation: () => Promise<T>): Promise<FileResult<T>> {
try {
return { ok: true, value: await operation() };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code === "ENOENT" ? "not_found" : "unknown";
return { ok: false, error: new FileError(code, error) };
}
}
export const nodeFiles: SyncFileSystem = {
joinPath: async (parts) => ({ ok: true, value: join(...parts) }),
readTextFile: (path) => attempt(() => readFile(path, "utf8")),
readBinaryFile: (path) => attempt(async () => new Uint8Array(await readFile(path))),
writeFile: (path, content) => attempt(() => writeFile(path, content)),
renameFile: (source, destination) => attempt(() => rename(source, destination)),
listDir: (path) =>
attempt(async () =>
(await readdir(path, { withFileTypes: true })).map(
(entry): SyncFileInfo => ({
name: entry.name,
kind: entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file",
}),
),
),
createDir: (path, options) => attempt(async () => void (await mkdir(path, options))),
remove: (path, options) => attempt(() => rm(path, options)),
};

View file

@ -19,6 +19,8 @@ import {
type BatchInfo, type BatchInfo,
decodeBatch, decodeBatch,
encodeBatch, encodeBatch,
FileSyncStateStore,
FileSystemSyncRemote,
FlushScheduler, FlushScheduler,
foldSession, foldSession,
HistoryUnavailableError, HistoryUnavailableError,
@ -35,8 +37,8 @@ import {
UnsyncedCommitsError, UnsyncedCommitsError,
uploadSession, uploadSession,
} from "../../src/sync/index.ts"; } from "../../src/sync/index.ts";
import { FileSyncStateStore, FileSystemSyncRemote } from "../../src/sync/node/index.ts";
import { tableOwner } from "../support.ts"; import { tableOwner } from "../support.ts";
import { nodeFiles } from "./node-files.ts";
const context = BACKGROUND_CONTEXT; const context = BACKGROUND_CONTEXT;
const SESSION = "session-1"; const SESSION = "session-1";
@ -51,7 +53,7 @@ afterEach(async () => {
async function remote(): Promise<FileSystemSyncRemote> { async function remote(): Promise<FileSystemSyncRemote> {
const directory = await mkdtemp(join(tmpdir(), "chord-sync-")); const directory = await mkdtemp(join(tmpdir(), "chord-sync-"));
directories.push(directory); directories.push(directory);
return new FileSystemSyncRemote(directory); return new FileSystemSyncRemote(nodeFiles, directory);
} }
/** A backend over the SQLite file at `path`, `:memory:` by default. */ /** A backend over the SQLite file at `path`, `:memory:` by default. */
@ -607,7 +609,7 @@ describe("host pieces", () => {
it("keeps sync state in files", async () => { it("keeps sync state in files", async () => {
const directory = await mkdtemp(join(tmpdir(), "chord-sync-state-")); const directory = await mkdtemp(join(tmpdir(), "chord-sync-state-"));
directories.push(directory); directories.push(directory);
const state = new FileSyncStateStore(join(directory, "sync")); const state = new FileSyncStateStore(nodeFiles, join(directory, "sync"));
expect(await state.get(SESSION, context)).toBeUndefined(); expect(await state.get(SESSION, context)).toBeUndefined();
await state.set(SESSION, { syncedHead: 3, pulling: 7 }, context); await state.set(SESSION, { syncedHead: 3, pulling: 7 }, context);
expect(await state.get(SESSION, context)).toEqual({ syncedHead: 3, pulling: 7 }); expect(await state.get(SESSION, context)).toEqual({ syncedHead: 3, pulling: 7 });

View file

@ -70,9 +70,6 @@
"@earendil-works/chord/sync": [ "@earendil-works/chord/sync": [
"../../../packages/chord/src/sync/index.ts" "../../../packages/chord/src/sync/index.ts"
], ],
"@earendil-works/chord/sync/node": [
"../../../packages/chord/src/sync/node/index.ts"
],
"@test/contracts/*": [ "@test/contracts/*": [
"../../chord/test/node/fixtures/plugins/contracts/*.ts" "../../chord/test/node/fixtures/plugins/contracts/*.ts"
], ],

View file

@ -26,7 +26,6 @@
"@earendil-works/chord/tui": ["./packages/chord/src/tui/index.ts"], "@earendil-works/chord/tui": ["./packages/chord/src/tui/index.ts"],
"@earendil-works/chord/tui/launcher": ["./packages/chord/src/node/tui/index.ts"], "@earendil-works/chord/tui/launcher": ["./packages/chord/src/node/tui/index.ts"],
"@earendil-works/chord/sync": ["./packages/chord/src/sync/index.ts"], "@earendil-works/chord/sync": ["./packages/chord/src/sync/index.ts"],
"@earendil-works/chord/sync/node": ["./packages/chord/src/sync/node/index.ts"],
"@acme/app/*": ["./packages/chord/test/examples/49-plugin-api/app/*.ts"], "@acme/app/*": ["./packages/chord/test/examples/49-plugin-api/app/*.ts"],
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"], "@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"], "@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],