Chương 16Phần 5: Chord và Pi Durable
Chord: state, delta, contract và RPC
Context, bảy phép toán delta, tracker, state nhân bản, contract, host và hot reload.
Trong chương này20 mục
- Vai trò: Chord là gì và ai dùng nó
- Mô hình tư duy: hai tầng trong một package
- Context: huỷ, telemetry và giá trị tường minh
- Bảy phép toán delta và dạng dây của chúng
- Tracker: draft, prepare, adopt
- Diff, apply và kiểm tra revision
- replicatedState: state quan sát được, không cần host
- Cell, derivation và hàng đợi publication
- Contract, schema và luật tương thích
- Host, object, reference và activation
- Root, Ctx và một lời gọi method
- Hot reload: prepare, gate, commit, retire
- Link, wire frame và connection
- Lần theo một thay đổi state qua link
- Pi Durable dùng Chord thế nào
- Output, SQLite và telemetry-sqlite
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Chương này đọc @earendil-works/chord (gọi tắt Chord), package mà Pi Durable dựa vào để giữ state của document, truyền huỷ (cancellation) và span telemetry, và ghi SQLite. Không có bài blog nào viết riêng về Chord, nên nguồn chính là tài liệu trong repo (README, docs/guide.md, docs/spec.md, docs/tutorial.md, src/delta/README.md) và code ở v1.4.0. Ta đi từ phần nền dùng được ở mọi nơi (Context, phép toán delta, tracker, state nhân bản) lên tầng host runtime (contract, object, reference, hot reload, link), lần theo một thay đổi state đi từ server qua link tới trình duyệt, rồi xem Pi Durable dùng những mảnh nào. Phần dùng các mảnh đó bên trong Durable nằm ở chương Durable: session, commit, document và storage.
Vai trò: Chord là gì và ai dùng nó#
Mô tả trong package.json là "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links" (package.json:4). README nói rõ hơn: Chord dựng ứng dụng từ các object sống trong nhiều tiến trình (một server, các worker, các tab trình duyệt) và có thể nạp lại nóng (hot reload) trong lúc đang chạy (README.md:3). Năm ý của README là khung cho cả chương:
- An **object** is state plus code at an address. It is created on demand and lives while someone references it.- Everyone gets a **reference**, never the object. References work the same locally and across processes, and survive reloads.- Every object knows its **principal**: an owner and a guest get different objects, with different methods.- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.- **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.Contracts are runtime values described with [TypeBox](https://github.com/sinclairzx81/typebox) schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.Package có chín điểm vào (entry point), khai báo ở package.json:8–55 và mô tả ở bảng của README (README.md:13–25):
| Import | Nội dung | Chạy ở |
|---|---|---|
@earendil-works/chord | Contract (remote, local, method, opaque), host (createHost, defineFacet, defineRegistry), link, lỗi; cộng phần nền chung: Context, JsonValue, Draft, copyJson, replicatedState | Node và trình duyệt |
/context | BACKGROUND_CONTEXT, withCancel, withAbortSignal, withTelemetry, traceIn, awaitWithContext | Node và trình duyệt |
/delta | Tracker giao dịch, diff cấu trúc, apply bất biến, bộ mã hoá/giải mã lô phép toán | Node và trình duyệt |
/output | OutputBuffer, Progress, boundOutput | Node và trình duyệt |
/node | Tiến trình host con (createHostManager, forkDriver, childControl, processLock) và nodeLoader | Node |
/sqlite, /sqlite/node, /sqlite/cloudflare | Facade SQLite bất đồng bộ, migration nguyên tử, adapter node:sqlite và Durable Object | Mọi adapter SQLite |
/telemetry-sqlite | createSqliteTelemetry(): ghi span và metric vào SQLite, truy vấn lại | Mọi adapter SQLite |
Lịch sử ngắn#
Commit đầu tiên, 28b49a6b3 "feat: add Chord runtime foundation" (Armin Ronacher, 2026-08-28), có phần code duy nhất là src/context.ts, tách ra từ packages/agent/src/harness/context.ts, kèm một PLANNING.md dài 859 dòng. Bản kế hoạch đó đặt mục tiêu một nền "application-neutral" cho plugin, service, RPC và state nhân bản, và cấm Chord phụ thuộc package Pi khác. State giao dịch được thêm ở 10d1ad621 (2026-09-21). Bản 1.2.0 thay toàn bộ runtime cũ (facet và service) bằng host runtime có kiểu như hiện nay, và gỡ ba package pi-client, pi-protocol, pi-server vốn chỉ phục vụ runtime cũ (CHANGELOG.md:78–81). Bản 1.3.0 đổi tên theo khái niệm (Handle thành Ref, epoch thành codeVersion) và nhận OutputBuffer từ Durable; bản 1.4.0 nhận các driver SQLite từ Durable và bắt buộc Context.telemetry (CHANGELOG.md:7–13).
README của Chord không gắn nhãn "experimental" như Pi Durable, nhưng cả ba bản minor 1.2, 1.3 và 1.4 đều có mục Breaking Changes. Hãy coi API là đang chuyển động.
Ai dùng Chord ở v1.4.0#
Trong monorepo chỉ hai package khai báo phụ thuộc vào Chord: Pi Durable (durable/package.json:153) và pi-env (env/package.json:48). Coding agent, pi-agent-core và pi-tui không dùng nó. Điều quan trọng hơn: không package nào ngoài chính Chord tạo host. Tìm createHost, defineFacet, memoryLinkPair, hostNamed trong packages/ chỉ ra kết quả trong packages/chord. pi-env chỉ nhập kiểu Context (remote-env.ts:5); Pi Durable dùng Context, JSON, delta, replicatedState, output và sqlite (xem mục Pi Durable dùng Chord thế nào). Tầng host runtime (contract, object, link, reload) ở bản này được kiểm chứng bằng bộ test và tutorial của chính nó; mục "Durable" trong guide mô tả cách một ứng dụng có thể đặt Harness của Durable vào một object của Chord (guide.md:756–782), nhưng không có code nào trong repo làm việc đó.
Mô hình tư duy: hai tầng trong một package#
Chord gồm hai tầng. Tầng dưới là các mảnh độc lập, không cần host: Context, JSON nghiêm ngặt, delta, state nhân bản, output, SQLite. Tầng trên là host runtime, dựng trên các mảnh đó: contract mô tả object, host phục vụ object, reference là cách duy nhất chạm tới object, link nối các host thành cụm, và reload thay code trong lúc chạy.
Host runtime import "@earendil-works/chord" (+ "/node" cho tiến trình con)
contract (remote · local, schema TypeBox) ── facet của plugin gọi host.serve(contract, factory)
host ── object (state + code tại một địa chỉ) ◀── reference (Ref) ◀── người gọi
root + Ctx + code version · hot reload: prepare → gate → commit → retire · registry
link (memory · IPC · WebSocket) ── Connection ── frame JSON: hold · shape · call · state ...
───────────────────────────────────────────────────────────────────────────────
Nền chung dùng được không cần host; đây là phần Pi Durable dùng
Context ("/context") huỷ + span telemetry + giá trị có kiểu, truyền tường minh
delta ("/delta") tracker (draft → prepare → adopt) · 7 phép toán · diff · apply · codec
replicatedState giá trị JSON bất biến, quan sát được, đổi theo revision nguyên tử
output ("/output") OutputBuffer (cửa sổ đầu/đuôi) · Progress (nhịp commit)
sqlite ("/sqlite…") facade bất đồng bộ · migration · node:sqlite · Durable Object
telemetry-sqlite span và metric vào SQLite, truy vấn lại
Từ vựng của phần còn lại (định nghĩa gốc ở spec.md:10–23):
| Thuật ngữ | Nghĩa | Trong code |
|---|---|---|
| Context | Giá trị bất biến mang tín hiệu huỷ, span telemetry hiện tại và các giá trị có khoá; truyền tay qua mọi lời gọi bất đồng bộ | Context, context/index.ts:11 |
| Revision, lô phép toán (op batch) | Một giá trị JSON bất biến; danh sách phép toán biến revision trước thành revision này | Op, Prepared |
| Tracker, draft | Chủ của một chuỗi revision; bản nháp đột biến được (mutable) của một thay đổi đang mở | Tracker, Change, Draft |
| State nhân bản (replicated state) | Giá trị JSON quan sát được, mỗi thay đổi là một revision; bản sao ở xa nhận ảnh chụp rồi phép toán | replicatedState, Cell |
| Contract | Giá trị lúc chạy mô tả một loại object: tên, version, input (địa chỉ), member | remote(), local() |
| Facet | Code của một plugin cho một loại host (server, worker, trình duyệt) | defineFacet() |
| Object, reference | Một thực thể của loại object tại một địa chỉ (type, input, principal); thứ get trả về, không bao giờ là object | ObjectImpl, Ref |
| Principal, variant | Danh tính người gọi do ứng dụng định nghĩa; tập member một object remote mở cho principal đó | createHost({ self, principalKey }) |
| Root, Ctx, code version | Một cây lời gọi ngắn; ngữ cảnh lời gọi của nó; một bộ code đã commit trên host | Ctx, types.ts:22 |
| Unit, snap, ops, reset | Một shape kèm ảnh chụp các member state; ảnh chụp; lô phép toán; ảnh chụp mới thay chuỗi cũ | frame shape, state |
Context: huỷ, telemetry và giá trị tường minh#
Bài toán: một thao tác bất đồng bộ (một lần ghi storage, một lời gọi mô hình) cần biết ba thứ của người gọi: có bị huỷ chưa, span telemetry nào là cha của nó, và vài giá trị riêng của ứng dụng. Cách hay gặp trong Node là AsyncLocalStorage, một ngữ cảnh "vô hình" đi theo promise. Chord chọn ngược lại: một giá trị bất biến, truyền tay làm tham số cuối của mọi hàm. Không có span "hiện tại" ngầm nào (README.md:91), và spec của host runtime cũng ghi "Explicit; no AsyncLocalStorage dependency" (spec.md:196).
/** Typed identity for one value carried by a {@link Context}. */export interface ContextKey<T> { readonly token: symbol; /** Type-only marker that prevents keys with different value types from being interchangeable. */ readonly valueType?: (value: T) => T;}/** Immutable invocation-scoped values passed explicitly through operations. */export interface Context { readonly abortSignal: AbortSignal | undefined; readonly telemetry: TelemetrySpan; value<T>(key: ContextKey<T>): T | undefined; toString(): string;}symbol bọc trong object đông cứng; trường valueType chỉ tồn tại ở mức kiểu để hai khoá khác kiểu giá trị không đổi chỗ được cho nhau.Hiện thực là một danh sách liên kết: mỗi lần "dẫn xuất" tạo một nút ContextValue trỏ về cha, và value(key) đi ngược chuỗi cho tới khi gặp khoá. Tín hiệu huỷ và span cũng chỉ là hai khoá riêng. Context gốc là EmptyContext, không mang gì:
abstract class BaseContext implements Context { abstract value<T>(key: ContextKey<T>): T | undefined; abstract toString(): string; get abortSignal(): AbortSignal | undefined { return this.value(ABORT_SIGNAL_CONTEXT_KEY); } get telemetry(): TelemetrySpan { return this.value(TELEMETRY_CONTEXT_KEY) ?? NOOP_TELEMETRY_CONTEXT; }}class ContextValue<T> extends BaseContext { readonly #parent: Context; readonly #key: ContextKey<T>; readonly #value: T; constructor(parent: Context, key: ContextKey<T>, value: T) { super(); this.#parent = parent; this.#key = key; this.#value = value; } value<Value>(key: ContextKey<Value>): Value | undefined { if (key.token === this.#key.token) return this.#value as unknown as Value; return this.#parent.value(key); } toString(): string { return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`; }}export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");telemetry trả về span no-op dùng chung của pi-telemetry, nên mọi Context đều gọi được telemetry.addEvent() mà không cần kiểm tra. TODO_CONTEXT là một EmptyContext khác, giống hệt BACKGROUND_CONTEXT trừ tên mà toString() in ra; code không ghi chú khi nào dùng cái nào.Các hàm dẫn xuất không bao giờ sửa cha:
/** Derive a context containing one additional or replaced value. */export function withContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context { return new ContextValue(parent, key, value);}/** Derive an explicit telemetry parent without changing cancellation or application values. */export function withTelemetry<T extends TelemetrySpan>( telemetry: T, context: Context,): Context & { readonly telemetry: T } { return withContextValue(TELEMETRY_CONTEXT_KEY, telemetry, context) as Context & { readonly telemetry: T };}/** Run a child span with a derived context whose telemetry member is that span. */export function traceIn<T>( context: Context, options: SpanOptions, fn: (context: Context) => T | Promise<T>,): Promise<T> { return context.telemetry.startSpan(options, (span) => fn(withTelemetry(span, context)));}/** * Derive a context cancelled by either the parent signal or the supplied signal. * The parent context remains unchanged. */export function withAbortSignal(signal: AbortSignal, context: Context): Context { const parentSignal = context.abortSignal; const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]); return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context);}/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */export function withoutAbortSignal(context: Context): Context { return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);}/** Derive an independently cancellable child context. */export function withCancel(context: Context): { readonly context: Context; readonly cancel: (reason?: unknown) => void;} { const controller = new AbortController(); return { context: withAbortSignal(controller.signal, context), cancel: (reason?: unknown) => controller.abort(reason), };}traceIn mở span con trên span của Context hiện tại rồi chạy fn với Context mới mang span con đó. withAbortSignal gộp tín hiệu cha và tín hiệu mới; withoutAbortSignal giữ mọi giá trị nhưng bỏ huỷ, chỉ dành cho việc dọn dẹp bắt buộc.awaitWithContext(promise, context) chờ một promise cho tới khi nó xong hoặc Context bị huỷ; huỷ chỉ làm người chờ bị từ chối, không huỷ công việc bên dưới (context/index.ts:134–156). Đây là đúng ngữ nghĩa Pi Durable hứa: "Cancelling a wait only cancels that wait, never the work" (durable/README.md:78).
Từ 1.4.0, telemetry là trường bắt buộc; một Context tự viết phải cung cấp nó, dùng span no-op khi không bật telemetry (CHANGELOG.md:7). Adapter telemetry (thứ mở span gốc) thuộc về host và không bao giờ nằm trong Context; Context chỉ mang span (README.md:75). README cũng nói rõ giới hạn hiện tại: các helper này chưa tự đo đạc host, method hay frame mạng của Chord (README.md:93). Kiểu span đến từ pi-telemetry, xem chương pi-env, telemetry và evals.
Để đối chiếu, đây là Ctx của host runtime: nó gắn với một root (một cây lời gọi), mang code version để mọi lời gọi lồng nhau chạy cùng một phiên bản code, và tự giải phóng các reference lấy qua get khi lời gọi xong.
/** * The explicit call context of one root (a short Chord call tree). Calls made with it carry its root id and code * version; `get` references are released when the call that received this ctx settles. */export interface Ctx<P = unknown> { readonly signal: AbortSignal; readonly rootId: string; readonly codeVersion: number; /** Who the root acts as: the caller at ingress, the host's own principal inside objects. */ readonly principal: P; /** * A reference for this call, released when the call settles. Resolves once it is ready: live, with every state * member of its variant (unless `state: false`). Rejects with the activation error, `aborted` when the ctx is * aborted or the call settles first, or `unavailable/ready_timeout` after the host's `readyTimeout`; the reference * is released then. */ get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Promise<Ref<C>>;}Bảy phép toán delta và dạng dây của chúng#
Mọi thay đổi state trong Chord được mô tả bằng một lô phép toán (op batch) trên JSON. Cùng một dạng tuple dùng trong bộ nhớ, trên dây và trên đĩa: Pi Durable lưu nguyên các tuple này làm bản ghi delta của document (chương Durable storage).
export type Seg = string | number;export type Path = readonly Seg[];export type NonEmptyPath = readonly [Seg, ...Seg[]];/** A path inline, or an id assigned by the encoder on second use. */export type PathRef<P extends Path = Path> = P | number;/** * Tuples are the form — in memory, on the wire, on disk. * * `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target * the root: the type forbids it. `p` and `m` may, because a tracked value can * itself be an array. Operation shape is not canonical: an array may be emptied by either * a replacement or a root splice. * * `Op` knows nothing about the path dictionary. Interning, id references and * omitted paths live in `WireOp` and exist only between `encode` and `decode`. */export type Op = | readonly ["r", JsonValue] | readonly ["s", NonEmptyPath, JsonValue] | readonly ["d", NonEmptyPath] | readonly ["a", NonEmptyPath, string] | readonly ["t", NonEmptyPath, number] | readonly ["p", Path, number, number, JsonValue[]] /** Reorder an array in place: `new[i] = old[permutation[i]]`. */ | readonly ["m", Path, number[]];s, d, a, t nhắm vào gốc (NonEmptyPath); chỉ r thay cả giá trị. p và m được nhắm vào gốc vì giá trị được theo dõi có thể là một mảng.| Tuple | Nghĩa | Ví dụ minh hoạ |
|---|---|---|
["r", value] | Thay toàn bộ giá trị | ["r", {"count": 0}] |
["s", path, value] | Gán một thuộc tính hay một phần tử mảng | ["s", ["status"], "done"] |
["d", path] | Xoá thuộc tính, hoặc bỏ một phần tử mảng | ["d", ["draft"]] |
["a", path, text] | Nối thêm vào cuối một chuỗi | ["a", ["output"], "ok\n"] |
["t", path, count] | Bỏ count đơn vị UTF-16 ở đầu chuỗi | ["t", ["output"], 1024] |
["p", path, index, remove, items] | Splice một mảng | ["p", ["rows"], 3, 0, [{"id": 4}]] |
["m", path, permutation] | Sắp lại mảng: new[i] = old[permutation[i]] | ["m", ["rows"], [2, 0, 1]] |
Bảng nghĩa gốc nằm trong delta README (delta/README.md:153–168), cùng một câu cần nhớ: "Batches are exact but not canonical". Một lô mô tả đúng thay đổi, nhưng cùng một thay đổi có thể ra các tuple khác nhau, và một tập sửa lớn có thể bị gộp thành một splice vùng, một s ở tổ tiên, hay r. Chỉ giá trị kết quả là hợp đồng; đừng suy ra nghĩa từ hình dạng phép toán (delta/README.md:94).
Dạng dây: encoder và decoder#
Một luồng (stream) state đổi liên tục (văn bản mô hình đang được stream vào ["generation", "message", ...]) lặp lại cùng một đường dẫn rất nhiều lần. WireOp thêm hai cách nén và không gì khác: định nghĩa một mã số cho đường dẫn, và bỏ đường dẫn khi nó trùng phép toán ngay trước trong cùng lô (delta/index.ts:42–66).
/** * Intern on SECOND use. A definition costs more than the path it replaces, so * interning on first use loses on the many paths written exactly once. */export function encoder(): Encoder { const seen = new Set<string>(); const ids = new Map<string, number>(); let nextId = 0; let previous: string | undefined; // last path in THIS batch return { encode(ops) { // Arity omission is scoped to a batch. Letting it span batches would make // a batch's first op depend on the previous batch's last one, so a reader // that skips or reorders a batch decodes into the wrong path. Ids are the // only cross-batch state, and the dictionary makes those explicit. previous = undefined; const out: WireOp[] = []; for (const op of ops) { if (op[0] === "r") { out.push(op); // A base batch is a RECOVERY POINT: a reader replays from the last one // with a fresh decoder. So everything after it must be self-contained. // Keeping ids across a replacement emits references to definitions the // reader never saw — recovery fails with an unresolvable path id. seen.clear(); ids.clear(); nextId = 0; previous = undefined; continue; } let ref: PathRef = path; const existing = ids.get(key); if (existing !== undefined) { ref = existing; } else if (seen.has(key)) { const id = nextId++; ids.set(key, id); out.push(["#", id, path]); // second use: define, then reference ref = id; } else { seen.add(key); // first use: inline }r.Ví dụ minh hoạ (suy ra từ code ở trên, không chạy thật): một encoder, ba lô liên tiếp
lô 1 Op: ["s",["status"],"running"] ["a",["output"],"x"] ["a",["output"],"y"]
dây: ["s",["status"],"running"] ["a",["output"],"x"] ["a","y"]
lần đầu: đường dẫn viết thẳng trùng phép trước: bỏ đường dẫn
lô 2 Op: ["a",["output"],"z"] ["s",["status"],"done"]
dây: ["#",0,["output"]] ["a",0,"z"] ["#",1,["status"]] ["s",1,"done"]
lần dùng thứ hai: định nghĩa mã 0 rồi tham chiếu
lô 3 Op: ["a",["output"],"!"]
dây: ["a",0,"!"]
lô 4 Op: ["r",{"status":"idle","output":""}] → encoder xoá từ điển; lô sau định nghĩa lại từ đầu
Decoder kiểm từng phép toán theo ngữ pháp dây (assertValidWireOp), dựng lại đường dẫn, và xoá từ điển khi gặp r (delta/index.ts:666–685). Quy tắc vận hành: một cặp encoder/decoder cho mỗi luồng state độc lập; dùng chung một kết nối không biến hai state thành một luồng (delta/index.ts:547–554). Sau một lỗi giải mã, bỏ decoder và bản sao, khôi phục từ một r sau đó (delta/README.md:179–182).
Đường dẫn là dữ liệu không tin cậy#
Phép toán có thể đến từ một plugin, hay từ một tool mà details lặp lại output của mô hình. Phép áp (applier) làm đúng việc parent[key] = value, nên ["s", ["__proto__", "isAdmin"], true] sẽ làm bẩn Object.prototype của cả tiến trình. Vì vậy ba đoạn __proto__, constructor, prototype bị cấm, applier ghi bằng Object.defineProperty thay vì phép gán, và chỉ đi theo thuộc tính riêng (own property) (delta/index.ts:120–131). Chỉ số mảng chỉ được trỏ vào phần tử có sẵn hoặc đúng một ô sau cuối: mảng thưa không sống sót qua JSON, và ["s", ["xs", 4294967290], 1] sẽ cấp phát một mảng hơn bốn tỉ ô từ một phép toán (delta/index.ts:289–306). Phía tracker, một thay đổi tại hoặc dưới khoá cấm được gộp thành s của tổ tiên an toàn gần nhất, hoặc r ở gốc (delta/README.md:170–177).
Tracker: draft, prepare, adopt#
Bài toán: Pi Durable sửa document pi.live mỗi 100 ms trong lúc mô hình stream. Nó cần (1) một API sửa tự nhiên (draft.output += chunk), (2) đúng lô phép toán của lần sửa đó để lưu làm delta và gửi cho người xem, (3) revision cũ vẫn dùng được trong lúc revision mới đang được ghi xuống storage, và (4) nếu storage lỗi thì bỏ thay đổi mà không động vào state đang có. Tracker giải cả bốn bằng ba bước tách rời: mở draft, chuẩn bị (prepare), nhận (adopt).
export interface Prepared<T extends object> { readonly base: T; readonly value: T; readonly ops: readonly Op[]; readonly baseRevision: number; abort(): void;}export interface Change<T extends object> { readonly state: Draft<T>; prepare(): Prepared<T>; abort(): void;}export interface Tracker<T extends object> { readonly value: T; readonly revision: number; beginChange(): Change<T>; prepareReplace(value: T): Prepared<T>; adopt(prepared: Prepared<T>): void;}Prepared mang base (revision gốc), value (revision mới) và ops biến cái này thành cái kia, cộng baseRevision để phát hiện tranh chấp.Cách dùng ngắn nhất có trong README (README.md:127–138): track(initial) nhận quyền sở hữu gốc trong O(1), beginChange() mở một draft, sửa change.state, prepare() trả về một Prepared, rồi adopt(prepared).
Draft là một lớp phủ proxy#
beginChange() không sao chép gì. Nó tạo một ngữ cảnh lớp phủ (overlay) mà gốc là một Proxy trên revision hiện tại; mỗi container được đọc qua draft có một nút phủ riêng, ghi lại phép ghi, phép xoá và các thao tác mảng của chính nó. Ghi một thuộc tính đi qua setProperty:
function setProperty(node: OverlayNode, property: PropertyKey, supplied: unknown): boolean { assertWritable(node.context); if (typeof property === "symbol") throw new TypeError("Symbol writes are not supported"); if (Array.isArray(nodeBase(node))) { if (property === "length") { setArrayLength(node, toArrayLength(supplied)); return true; } const index = arrayIndex(property); if (index === undefined) throw new TypeError("Only array indices and length can be written"); if (index > arrayLength(arrayOverlay(node))) throw new TypeError("Overlay arrays cannot contain holes"); setArrayIndex(node, index, clonePlacement(supplied)); return true; } const key = String(property); if (supplied === undefined) return deleteProperty(node, key); const stored = clonePlacement(supplied); const current = objectValue(node, key); const wasDeleted = isObjectDeleted(node, key); if (!wasDeleted && !isContainer(stored) && current === stored) return true; setObjectWrite(node, key, stored); if (wasDeleted && Object.hasOwn(nodeBase(node), key)) { if (node.readded === undefined) node.readded = new Set(); node.readded.add(key); } deleteObjectDeletion(node, key); markDirty(node); return true;}clonePlacement dùng copyJson, vốn từ chối JSON không nghiêm ngặt trước khi draft đổi). Gán undefined cho thuộc tính là xoá nó; ghi lại đúng giá trị nguyên thuỷ đang có thì không làm gì.markDirty() đưa nút vào danh sách bẩn của ngữ cảnh và đánh dấu mọi tổ tiên là "cây con bẩn" (tracker.ts:761–766). Mảng có cấu trúc riêng: một cây các đoạn (piece) "lấy từ mảng gốc" và "chèn mới", tổ chức thành treap (cây nhị phân tìm kiếm giữ cân bằng bằng độ ưu tiên ngẫu nhiên), nên push, splice, reverse, sort chỉ sửa cây piece chứ không chép mảng (tracker.ts:1177–1243). Handle đọc từ draft đi theo phần tử qua các lần đổi chỉ số (delta/README.md:150–151).
prepare(): sinh phép toán từ các nút bẩn#
prepare(): Prepared<T> { if (this.#settled) throw new Error("Change has already been settled"); const context = this.#context!; assertWritable(context); context.status.value = "prepared"; try { if (!context.replacement) context.ops = context.dirty.length === 0 ? [] : emitOperations(context); const prepared = materializePrepared<T>(context); this.#preparedStatus = context.status; this.#settled = true; return prepared; } catch (error) { context.status.value = "aborted"; clearContext(context); this.#settled = true; throw error; } finally { this.#context = undefined; }}prepared (chỉ đọc); khi prepare() trả về, ngữ cảnh đã được dọn và mọi lần dùng draft hay handle đọc từ nó đều ném lỗi (delta/README.md:18). Lỗi trong lúc sinh phép toán chuyển ngữ cảnh sang aborted và dọn sạch nó.emitOperations() đi qua các nút bẩn theo độ sâu, sinh phép toán cho object (ghi, xoá, xoá-rồi-thêm-lại để giữ thứ tự khoá) và cho mảng (một kế hoạch splice và hoán vị). Với giá trị từng thuộc tính, quyết định nằm ở một hàm nhỏ:
function emitChangedValue( operations: Op[], path: NonEmptyPath, before: JsonValue | undefined, after: JsonValue,): boolean { if (!isContainer(after) && before === after) return false; if (isContainer(before) && isContainer(after) && equalTrustedJson(before, after)) return false; if (typeof before === "string" && typeof after === "string") { // Slice/equality stays on V8's rope fast path; startsWith scans the entire // accumulated prefix on every append. if (after.length > before.length && after.slice(0, before.length) === before) { operations.push(["a", path, after.slice(before.length)]); return true; } const shared = overlap(before, after, 65_536); if (shared > 0) { operations.push(["t", path, before.length - shared]); if (after.length > shared) operations.push(["a", path, after.slice(shared)]); return true; } } operations.push(["s", path, after]); return true;}overlap(before, after, 65_536) tìm hậu tố dài nhất của chuỗi cũ là tiền tố của chuỗi mới (delta/index.ts:80–110). Đây là trường hợp một cửa sổ đuôi của output: cắt đầu rồi nối thêm.Hai giới hạn giữ lô phép toán nhỏ hơn giá trị. Quá MAX_DELTA_OPERATIONS (4.096) phép toán thì cả lô bị gộp thành một ["r", root] (tracker.ts:1666–1669). Và với thao tác mảng phổ biến (chèn, xoá mà các phần tử còn lại giữ thứ tự), kế hoạch được tính chỉ từ các piece, không duyệt phần tử:
/** * The plan when the retained base elements keep their order, the common case of inserts and removals: computed from the * pieces alone, so appending to a long array costs no walk over its elements. `undefined` when base elements moved. */function orderedArrayPlan(baseLength: number, pieces: readonly Piece[]): ArrayPlan | undefined { // Retained base ranges in piece order, `[first, last]` each; ordered means strictly ascending without overlap. const ranges: number[] = []; for (const piece of pieces) { if (piece.kind !== "base" || piece.length === 0) continue; if (piece.step !== 1 && piece.length > 1) return undefined; if (ranges.length > 0 && piece.start <= ranges[ranges.length - 1]!) return undefined; ranges.push(piece.start, piece.start + piece.length - 1); } // Unretained runs from the end backwards, as `start, length` pairs: after the last range, between ranges, before the first. const removeRuns: number[] = []; let end = baseLength; for (let index = ranges.length - 2; index >= -2; index -= 2) { const start = index >= 0 ? ranges[index + 1]! + 1 : 0; if (end > start) removeRuns.push(start, end - start); if (index >= 0) end = ranges[index]!; } return { removeRuns, permutation: undefined, insertRuns: insertRunsOf(pieces) };}Phép toán xong thì materializePrepared() dựng revision mới: không có phép toán thì value === base; thay đổi chỉ ở object thì chép thẳng các nút bẩn; còn lại thì áp lô phép toán bất biến lên base, chia sẻ mọi cây con không đổi (tracker.ts:325–349).
Ví dụ minh hoạ (lần theo code, không chạy): ví dụ của README
tracker = track({ output: "", count: 0 }) revision 0
change = tracker.beginChange()
change.state.output += "done\n" → nút gốc: writeKey "output"
change.state.count += 1 → nút gốc: writes {output, count}, gốc vào danh sách bẩn
prepared = change.prepare()
ops = [ ["a", ["output"], "done\n"], "done\n" bắt đầu bằng "" → append
["s", ["count"], 1] ] số nguyên thuỷ đổi → set
value = { output: "done\n", count: 1 } object mới; base không đổi
tracker.value === prepared.base authority chưa đổi
tracker.adopt(prepared) revision 1, tracker.value === prepared.value
adopt(): một phép đổi con trỏ không thể thất bại#
beginChange(): Change<T> { const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value); this.#register(context); return new ChangeImpl(context);}adopt(prepared: Prepared<T>): void { const context = PREPARED.get(prepared as object); if (context?.owner !== this.#owner) throw new Error("Prepared change belongs to a different tracker"); if (context.status.value === "consumed") throw new Error("Prepared change has already been used"); if (context.status.value === "aborted") throw new Error("Prepared change has been aborted"); if (context.status.value === "stale") throw new Error("Prepared change is stale"); if (context.status.value !== "prepared") throw new Error("Prepared change is not ready"); if (context.baseRevision !== this.#revision) { context.status.value = "stale"; clearContext(context); throw new Error("Prepared change is stale"); } if (this.#value !== prepared.base) { context.status.value = "stale"; throw new Error("Prepared change is stale"); } // Materialization happened during prepare. Adoption is an infallible pointer // swap so storage failure can discard the candidate without touching authority. this.#value = prepared.value; context.status.value = "consumed"; this.#revision += 1; this.#invalidate(context);}prepare(). adopt() chỉ kiểm tra rồi đổi con trỏ, nên tầng storage có thể vứt bỏ ứng viên khi ghi lỗi mà không chạm vào authority. #invalidate đánh dấu mọi draft và preparation cạnh tranh là stale.Nhiều change có thể mở hay đã prepare từ cùng một revision; adopt một cái làm mọi cái khác hết hạn: draft đang mở không dùng được nữa, preparation cạnh tranh bị adopt() từ chối (delta/README.md:124–136). Đó chính là cách Pi Durable dùng tracker trong một commit: prepare mọi document khi callback xong, ghi storage, chỉ adopt sau khi ghi thành công (durable/session/transaction.ts:709–712, transaction.ts:727–739).
Ai được đột biến cái gì#
Tracker không đóng băng (freeze) và không sao chép phòng thủ. Bất biến là một hợp đồng sở hữu: đột biến trái phép không bị phát hiện, nó làm hỏng state trong im lặng (delta/README.md:6–7). Bảng quyền đột biến trong delta README (delta/README.md:28–40) tóm lại: gốc đưa cho track() hay replace() không được sửa nữa; change.state chỉ sửa được khi change còn mở; giá trị bên ngoài đặt vào draft vẫn của người gọi (draft giữ bản sao); mọi revision, mọi lô phép toán và mọi giá trị đọc từ state đều không được sửa.
Diff, apply và kiểm tra revision#
Tracker sinh phép toán từ các lần ghi qua draft. Có lúc không có draft: một derivation tính lại giá trị mới, hay một ứng dụng soi gương (mirror) một store bất biến bên ngoài bằng cell.replace(ctx, next). Khi đó Chord so hai revision bằng diffRevisions():
/** Compute a compact operation batch from two immutable JSON revisions. */export function diffRevisions(before: JsonValue, after: JsonValue): Op[] { const operations: Op[] = []; diffValue(before, after, [], operations); if (overflowedBatches.has(operations)) return [["r", after]]; if (operations.length === 0 || operations[0]?.[0] === "r") return operations; let deltaCost = 2; for (const operation of operations) deltaCost += operationCost(operation) + 1; if (deltaCost < 65_536) return operations; const snapshotCost = jsonCost(after) + 6; return deltaCost >= snapshotCost ? [["r", after]] : operations;}Diff dựa vào danh tính container: một revision mới của cùng state ({ ...state.value, changed }) chia sẻ các cây con không đổi, nên phép so === bỏ qua chúng ngay. Với mảng, nó lần lượt thử tiền tố và hậu tố chung, so theo vị trí, chuỗi con theo danh tính, các mỏ neo danh tính (dãy con tăng dài nhất), rồi LCS "ngữ nghĩa" có trần 65.536 ô, và nhận ra một hoán vị thuần để phát ra m (diff.ts:284–418). Chuỗi dùng cùng luật a/t/s như tracker (diff.ts:63–76). Đây là lý do guide khuyên giữ chia sẻ cấu trúc giữa các revision (delta/README.md:88): Cell.replace() chỉ diff mặc định khi giá trị mới dùng lại ít nhất một container của giá trị cũ ở cùng khoá (cell.ts:275–283).
Áp phép toán: bất biến và đột biến#
/** Apply one decoded operation batch without mutating the previous immutable value. */export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T { return applyImmutableBatches(target, [ops]);}/** * Apply decoded operation batches as one final-result-only replay. * * Containers copied for an earlier batch may be mutated while applying a later * batch, so this deliberately exposes no intermediate revisions. */export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T { let root = target as unknown as JsonValue; const owned = new WeakSet<object>(); for (const ops of batches) { for (const op of ops) { assertValidOp(op); if (op[0] === "r") { root = op[1]; continue; } if (op[0] === "p") { const appended = appendCopy(root, op, owned); if (appended !== undefined) { root = appended; continue; } } root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned); root = applyOps(root, [op]); } } return root as unknown as T;}owned nhớ container nào đã được chép trong lần áp này, nên nhiều phép toán vào cùng một nhánh chỉ chép nhánh đó một lần. Kết quả chia sẻ container với cả đầu vào lẫn payload của phép toán.applyImmutableBatches() phát lại nhiều lô với một phạm vi copy-on-write chung, nên không lộ revision trung gian nào; Pi Durable dùng nó để đọc document từ base cộng chuỗi delta trong MemoryStorage (durable/storage/memory.ts:671–674). Bản đột biến apply() nhận một bản sao riêng và "nuốt" payload của lô vào nó: chép lô cho mỗi bản sao, đừng áp một lô trong bộ nhớ cho hai bản sao đột biến (delta/index.ts:335–345; delta/README.md:67–83). Backend SQLite và IndexedDB của Durable dùng apply() trên giá trị vừa đọc từ đĩa (với SQLite là kết quả của JSON.parse), nên bản sao là riêng của chúng (durable/storage/sqlite/storage.ts:649–655).
Khi tracker tự áp lô của chính nó, nó dùng applyImmutableTrusted(): bỏ kiểm tra hình dạng và an toàn đường dẫn vì lô do chính nó sinh, và tự viết splice bằng copyWithin (apply-immutable-trusted.ts:14–61); lô có p hay m thì vẫn đi đường applyImmutable() vì splice gốc nhanh hơn với lô lớn (tracker.ts:343–349).
Kiểm tra revision#
JsonRevisionValidator kiểm một revision là JSON nghiêm ngặt (mảng dày, object thuần, không chu trình, số hữu hạn) và nhớ bằng WeakSet các container đã kiểm ở revision trước, nên một revision mới chỉ tốn công cho phần đã đổi (revision-validator.ts:7–42). Ở v1.4.0 không file nguồn nào nhập lớp này, và delta/index.ts không xuất nó; benchmark duyệt draft chỉ băm nội dung file để ghi lại phiên bản đo. Kiểm tra thật sự ở các ranh giới khác: placement qua draft được copyJson kiểm, gốc trao cho track() thì được tin (delta/README.md:96–113), và state đi qua link được kiểm theo schema của contract (mục link).
replicatedState: state quan sát được, không cần host#
Tracker cho revision và phép toán; một giao diện còn cần quan sát: đọc giá trị hiện tại, được báo khi nó đổi, theo đúng thứ tự, và một người nghe chậm không được chặn người khác. replicatedState là lớp đó, độc lập với host; README ghi Durable dùng nó cho state của document, view và task graph (README.md:154–156).
export interface ReplicatedStateDelivery { readonly kind: "hydrate" | "update"; readonly sequence: number;}export interface ReplicatedState<T> { /** * Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an * in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values. */ readonly value: T | undefined; /** * Each subscription serializes callbacks, awaiting hydration before updates. Values are immutable and may * share unchanged revision data. At most 100 deliveries wait behind the running callback; overflow keeps * only the newest pending value/context/delivery, so update sequences may skip. Failures are reported in * isolation and delivery continues. Unsubscribe discards pending work without aborting or joining a callback. */ subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => Promise<void>): () => void; subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;}export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> { readonly value: T; /** * Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns. * Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it. */ change(context: Context, mutate: (draft: Draft<T>) => void): void; /** * Atomically take immutable ownership of an alias-free strict-JSON replacement. * The caller must not mutate the transferred root after this call. */ replace(context: Context, value: T): void;}delivery (hydrate hay update, kèm số thứ tự). Giá trị không bị đóng băng và có thể chia sẻ container với nơi cung cấp: không được sửa.Một change() thành công công bố đúng một revision nguyên tử; callback ném lỗi thì giá trị và số thứ tự giữ nguyên (README.md:169–173). Hiện thực là tracker cộng một publisher:
change(context: Context, mutate: Parameters<MutableReplicatedState<T>["change"]>[1]): void { if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback"); this.#changing = true; let prepared: Prepared<T>; try { const change = this.#tracker.beginChange(); try { const outcome = (mutate as (draft: typeof change.state) => unknown)(change.state); if (isPromiseLike(outcome)) { void Promise.resolve(outcome).catch(() => undefined); throw new TypeError("Replicated state change callbacks must be synchronous"); } prepared = change.prepare(); } catch (error) { change.abort(); throw error; } } finally { this.#changing = false; } this.#tracker.adopt(prepared); if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);}replace(context: Context, value: T): void { if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback"); const prepared = this.#tracker.prepareReplace(value); this.#tracker.adopt(prepared); if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);}Giao từng subscriber một, có trần#
ReplicatedStatePublisher.subscribe() giao ngay giá trị hiện tại làm lần hydrate (lần nạp giá trị đầu tiên), với BACKGROUND_CONTEXT vì hydrate không có người gây ra (state.ts:201–212). publish() chống tái nhập (reentrancy): một subscriber đổi state ngay trong callback thì revision mới xếp hàng sau revision đang giao, không chen lên trước (state.ts:214–238). Mỗi subscriber có hàng đợi riêng:
push(frame: StateDelivery<T>): void { if (this.#closed) return; if (this.#pending.length === 100) { // A cold replica can queue updates reentrantly before this subscriber's first hydration starts. const hydration = this.#started ? undefined : this.#pending[0]; this.#pending.length = 0; if (hydration !== undefined) this.#pending.push(hydration); } this.#pending.push(frame);}drain(): void { if (this.#running || this.#closed) return; this.#running = true; for (let frame = this.#pending.shift(); frame !== undefined; frame = this.#pending.shift()) { this.#started = true; try { const result: unknown = this.#listener(frame.value, frame.context, frame.delivery); if (isPromiseLike(result)) { void Promise.resolve(result).then( () => this.#resume(), (error: unknown) => { this.#report(error); this.#resume(); }, ); return; } } catch (error) { this.#report(error); } } this.#running = false;}Gắn vào một nguồn revision bên ngoài#
Dạng thứ hai, replicatedState(source, { onError }), không tự sở hữu tracker. Nó gắn vào một nguồn có thẩm quyền (authoritative) đã tự commit revision, như Session của Pi Durable:
/** One immutable authoritative revision committed after an attachment snapshot. */export interface ReplicatedStateSourceFrame<T> { /** Monotonic source cursor. The first frame after a snapshot must be `snapshot.cursor + 1`. */ readonly cursor: number; /** The exact immutable value produced by this commit. */ readonly value: T; /** The exact immutable operation batch that produced `value` from the preceding source revision. */ readonly ops: readonly Op[]; readonly context: Context;}export interface ReplicatedStateSourceAttachment<T> { /** Fixed immutable snapshot captured at the atomic attachment boundary. */ readonly snapshot: { readonly value: T; readonly cursor: number }; /** * Install the sole listener and synchronously drain every buffered frame in source commit order. * This method is single-use. After it begins, every new committed frame must also be delivered in order * until disposal, including commits made reentrantly while a prior frame is being delivered. */ activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void; /** Stop delivery and release source resources. Disposal must be idempotent. */ dispose(): void;}/** * An authoritative immutable revision source. * * `attach()` must synchronously and atomically capture one snapshot and register the returned attachment to buffer * every later committed frame. The snapshot must include every commit before that boundary; buffered frames must * include every commit after it, with no overlap or gap. Snapshot values, frame values, and operation batches are * immutable and remain valid after delivery. Chord only publishes these references; it never applies or re-diffs them. */export interface ReplicatedStateSource<T> { attach(): ReplicatedStateSourceAttachment<T>;}attach() phải chụp ảnh và đăng ký nhận frame trong cùng một bước đồng bộ, để mọi commit trước ranh giới nằm trong ảnh chụp và mọi commit sau nằm trong frame, không trùng không hở. Chord chỉ công bố các tham chiếu này, không bao giờ áp hay diff lại chúng.Phía Chord kiểm tra hợp đồng đó: cursor (số thứ tự tăng dần của frame) của frame phải là cursor trước cộng một, lệch là lỗi, state tự gỡ nguồn và báo qua onError (state.ts:326–339). Pi Durable hiện thực nguồn này bằng CommittedStateSource: attach() lấy giá trị và cursor hiện tại, advance(value, ops, context) tăng cursor và phát một frame cho mọi attachment sau mỗi commit chạm tới document hay view (durable/session/observation.ts:25–62; chi tiết ở chương Durable storage).
Cell, derivation và hàng đợi publication#
Bên trong host runtime, state của object nằm trong cell, khai báo bằng self.state(name, schema, initial). Cell cũng dùng tracker, nhưng có mô hình đăng ký riêng (README.md:201–202): đồng bộ, đi qua một hàng đợi chung của tiến trình, và giao sự kiện delta chứ không chỉ giá trị. Giao diện mọi nguồn state (cell, derivation, member state của một reference) cùng tuân theo:
export type StateEvent<T> = | { readonly type: "snap"; readonly value: T } | { readonly type: "ops"; readonly ops: readonly Op[]; readonly value: T } | { readonly type: "reset"; readonly value: T };/** A readable state source: a cell, a derivation, or a reference's state member. Values are immutable by contract. */export interface Readable<T> { readonly value: T; /** Called with the current value immediately, then with every new value. */ subscribe(listener: (value: T) => void): () => void; /** Delta stream: a snapshot immediately, then ops, or a reset after a migration or rebind. */ events(listener: (event: StateEvent<T>) => void): () => void;}Tracker chỉ theo dõi container, mà một cell có thể chứa số hay chuỗi. Cell vì vậy bọc giá trị trong { v: value } và gỡ lớp bọc khỏi phép toán trước khi công bố:
/** The tracker tracks containers, so a cell tracks `{ v: value }` and strips the box from published ops. */interface Box<T> { v: T;}function unbox(ops: readonly Op[], value: unknown): Op[] { const result: Op[] = []; for (const op of ops) { if (op[0] === "r" || op[1].length === 0) return [["r", value as JsonValue]]; const path = op[1].slice(1); switch (op[0]) { case "p": result.push(["p", path, op[2], op[3], op[4]]); break; case "m": result.push(["m", path, op[2]]); break; default: // s/d/a/t on the value itself replace it. if (path.length === 0) return [["r", value as JsonValue]]; if (op[0] === "d") result.push(["d", path as [string | number, ...(string | number)[]]]); else result.push([op[0], path as [string | number, ...(string | number)[]], op[2]] as Op); } } return result;}v (gán số mới cho cell kiểu số) trở thành ["r", value] của cell./** * Mutates a draft (only mutated paths are published), or returns a new value (`n => n + 1`). A returned value is * always validated against the schema. The draft is unusable after `fn` returns. */change(ctx: Ctx, fn: (draft: Draft<T>) => T): void;change(ctx: Ctx, fn: (draft: Draft<T>) => void): void;change(ctx: Ctx, fn: (draft: Draft<T>) => unknown): void { this.#guard(ctx); if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`); const change = this.#tracker.beginChange(); let prepared: ReturnType<typeof change.prepare>; try { const draft = change.state as Draft<Box<T>>; const result: unknown = fn(draft.v as Draft<T>); if (result instanceof Promise) throw new TypeError("cell.change callbacks must be synchronous"); if (result !== undefined) { if (result !== draft.v) assertValid("state", `state ${this.key}`, this.schema, result); draft.v = result as Draft<T>; } prepared = change.prepare(); } catch (error) { change.abort(); throw error; } this.#publish(prepared, "ops");}guard(ctx) là cổng ghi của object: không ghi trong factory, không ghi với ctx đã huỷ hay ctx của host khác, và chịu kiểm tra code version khi reload (object.ts:1546–1553). Callback trả về một giá trị (n => n + 1) thì giá trị đó được kiểm theo schema rồi gán vào v.#publish() là chỗ cell khác replicatedState: trước khi adopt, nó tính lại mọi derivation phụ thuộc (bắc cầu) trên giá trị ứng viên, và ở chế độ dev kiểm chúng theo schema (cell.ts:110–115); một derivation ném lỗi, hay một giá trị sai schema ở chế độ dev, huỷ preparation và cell giữ nguyên. Chỉ khi mọi thứ hợp lệ, nó adopt cell, gửi sự kiện, rồi cài giá trị mới cho các derivation, tất cả bên trong holdPublications:
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */#publish( prepared: ReturnType<Tracker<Box<T>>["prepareReplace"]>, type: "ops" | "reset", validate = true, ops?: readonly Op[],): void { if (prepared.ops.length === 0 && type === "ops") { this.#tracker.adopt(prepared); return; } const value = prepared.value.v; let candidates: Map<Derived<unknown>, unknown>; try { if (validate) this.validate(value); candidates = computeDependents(this as Source<unknown>, value, validate); } catch (error) { prepared.abort(); throw error; } holdPublications(() => { this.#tracker.adopt(prepared); const published = ops !== undefined && ops.length > 0 ? ops : unbox(prepared.ops, value); this.fanout.emit(type === "reset" ? { type, value } : { type, ops: published, value }); for (const [derived, next] of candidates) derived.install(next); });}Lý do của holdPublications và enqueue được viết ngay đầu publish.ts, theo đúng lối "bài toán, ví dụ, lời giải":
// One FIFO queue for every observer notification (state events, shape pushes, registry tables, listener// invocations).//// Problem: notifying observers synchronously from inside a write lets a re-entrant write overtake the write being// published. Example: subscriber A reacts to n = 1 by writing n = 2; subscriber B then sees 2 before 1.// Solution: a publication first adopts every revision it makes (cells and derivations), then enqueues its// notifications. Notifications run in enqueue order, so every observer sees revisions in adoption order.// `holdPublications` defers delivery until a multi-step change (a reload commit) is complete.const queue: (() => void)[] = [];let held = 0;let draining = false;/** Runs `task` after everything enqueued before it. Tasks must not throw (report instead). */export function enqueue(task: () => void): void { queue.push(task); drain();}/** Delivers nothing until `fn` returns, then everything it enqueued, in order. */export function holdPublications<T>(fn: () => T): T { held++; try { return fn(); } finally { held--; drain(); }}n = 1 bằng cách ghi n = 2, và subscriber B thấy 2 trước 1. Với nó, mọi người quan sát thấy revision theo thứ tự adopt.Derivation (self.derive(sources, fn)) là một phép chiếu đồng bộ. Nguồn là cell hay derivation cùng object thì được tính lại ngay trong lần ghi (như trên); nguồn là thứ khác (member state của một reference) thì được đăng ký và tính lại khi nguồn công bố. Mỗi lần cài giá trị mới, derivation phát lô phép toán bằng diffRevisions() giữa giá trị cũ và mới (cell.ts:388–396). Derivation không có tên và được tạo mới ở mỗi lần reload; cell có tên và giữ giá trị qua reload (mục hot reload).
Contract, schema và luật tương thích#
Bài toán: plugin được build riêng và chở đi dưới dạng bundle JavaScript, không còn TypeScript. Một bên cung cấp (provider) và một bên dùng (consumer) có thể được build từ hai phiên bản khác nhau, ở hai tiến trình khác nhau. Runtime phải tự kiểm hai bên có khớp không và kiểm dữ liệu đi qua link. Vì vậy contract là giá trị lúc chạy; kiểu TypeScript được suy ra từ nó (guide.md:76). Ví dụ nhanh của README:
// contract.ts: shared by provider and consumersexport const Counter = remote("demo.counter", { version: 1, input: Type.Object({ name: Type.String() }), host: hostNamed("server"), // where its objects live; callers never name a host variants: { default: { state: { count: Type.Number() }, methods: { increment: method(Type.Object({ by: Type.Number() })) }, }, },});const host = createHost({ name: "server", self: { kind: "server" } });host.serve(Counter, ({ name }, _principal, self) => { const count = self.state("count", Type.Number(), 0); return { variant: "default", count, increment: async ({ by }, ctx) => { count.change(ctx, (n) => n + by); }, };});await host.root(async (ctx) => { const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends clicks.count.subscribe((n) => console.log("count", n)); await clicks.increment({ by: 2 }, ctx); // logs 0, then 2});{ name } là địa chỉ của object, không phải tham số của method. host: hostNamed("server") nói object sống ở đâu, nên người gọi không bao giờ nêu tên host.Có hai loại contract. remote() cho loại object gọi được từ host khác: mọi thứ đi qua dây đều có schema (input, member state theo từng variant, tham số và kết quả của method), và bắt buộc có placement. local() cho object chỉ dùng trong host: member có thể là bất cứ gì (hàm, callback, object gốc của Node), nhưng phải được liệt kê (method, opaque("name@version"), hoặc một schema state) để một reference còn đang loading biết ref.x là method, state hay giá trị (spec.md:56).
export function remote<const D extends RemoteDef>( name: string, def: D & { readonly host: PlacementFor<Static<D["input"]>> },): RemoteContract<D> { checkHeader(name, def.version); const placement = def.host as Placement | undefined; if (placement === undefined || !Value.Check(PlacementSchema, placement)) throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`); // A JSON copy: the description travels, and a placement object may carry nothing else. const host: Placement = "name" in placement ? { name: placement.name } : { prefix: placement.prefix, key: placement.key }; const variants: Record<string, Record<string, MemberDescription>> = {}; if (Object.keys(def.variants).length === 0) throw new TypeError(`remote contract ${name} has no variants`); for (const [kind, variant] of Object.entries(def.variants)) { const members: Record<string, MemberDescription> = {}; for (const [key, schema] of Object.entries(variant.state ?? {})) members[key] = describeMember(key, schema, "remote"); for (const [key, spec] of Object.entries(variant.methods ?? {})) { if (key in members) throw new TypeError(`member ${key} is both state and method in ${name}.${kind}`); members[key] = describeMember(key, spec, "remote"); if (members[key].kind !== "method") throw new TypeError(`methods.${key} is not a method()`); } variants[kind] = members; } const description: Description = { name, scope: "remote", version: def.version, input: def.input, host, variants }; return finish<RemoteContract<D>>({ scope: "remote", name, version: def.version, input: def.input, def, description, });}finish() gắn fingerprint = hash64(canonicalJson(description)) (contract.ts:210–212): một hash 64 bit không mật mã, hai làn FNV-1a, đồng bộ và chạy được trong trình duyệt; contract là thứ được tin nên va chạm không phải bề mặt tấn công (schema.ts:21–31).Mô tả (Description) là một object JSON thuần, có schema riêng (contract.ts:147–156), vì nó đi qua dây làm "token" của consumer trong frame hold. Ở phía nhận, contractFromDescription() dựng lại một contract chỉ dùng để bind và kiểm, không mang kiểu TypeScript (contract.ts:268–286).
Input là địa chỉ#
{ session: "S1", conversation: 1 } luôn chỉ cùng một object. Input được chuẩn hoá (điền mặc định, bỏ trường lạ), kiểm, rồi tuần tự hoá chính tắc; trường bằng giá trị mặc định bị bỏ khỏi danh tính, nên thêm một trường tuỳ chọn có mặc định không đổi địa chỉ cũ (guide.md:133; schema.ts:45–55). Đổi một giá trị mặc định thì phải đổi tên loại object.
Placement của loại remote là dữ liệu: hostNamed("server"), hoặc hostFamily("session:", "session") nghĩa là host session: cộng trường session của input (contract.ts:63–92). Vì placement nằm trong mô tả, nó nằm trong fingerprint: chuyển một loại object sang host khác là thay đổi phá vỡ (guide.md:108–113).
Luật tương thích#
/** How a consumer token binds to a provider: directly or through an `accepts` adapter. Throws contract_mismatch. */export function bind(provider: AnyContract, accepts: Accepts | undefined, consumer: AnyContract): Adapter | undefined { if (provider.fingerprint === consumer.fingerprint) return undefined; // A moved type is another type for its consumers: no adapter reaches across hosts. if (!samePlacement(provider.description, consumer.description)) { throw new ChordError( "contract_mismatch", `${provider.name} lives on ${placementOf(provider.description)}, the consumer expects ${placementOf(consumer.description)}`, ); } if (provider.version === consumer.version) { const reason = coverage(provider.description, consumer.description); if (reason === undefined) return undefined; throw new ChordError( "contract_mismatch", `${provider.name}@${provider.version} does not cover the consumer's description (${reason}); forgotten version bump?`, ); } const adapter = accepts?.[consumer.version]; if (adapter === undefined) { throw new ChordError( "contract_mismatch", `${provider.name}: provider version ${provider.version}, consumer version ${consumer.version}, no accepts adapter`, ); } return adapter;}"Phủ" (cover) được định nghĩa từng member trong coverage(): mỗi member của consumer phải có ở provider với cùng loại; method thì hai bên cùng nhận hoặc cùng không nhận tham số, provider nhận được mọi tham số consumer có thể gửi, và kết quả của provider vừa với kết quả consumer chờ; state thì giá trị của provider vừa schema của consumer; opaque thì cùng name@version (contract.ts:288–314). Câu hỏi "vừa" trả lời bằng fits(sub, sup), một phép kiểu con cấu trúc bảo thủ trên JSON Schema: chỉ trả về đúng khi mọi giá trị hợp lệ theo sub chắc chắn hợp lệ theo sup, và một từ khoá nó không mô hình (not, allOf, $ref...) buộc hai schema phải giống hệt (schema.ts:128–160).
Hệ quả thực hành, theo bảng của guide (guide.md:147–152): thêm method, thêm tham số tuỳ chọn, thêm trường kết quả hay trường state thì giữ version; bỏ hay đổi tên member, biến tham số thành bắt buộc, đổi kiểu thì tăng version và cung cấp accepts cho consumer cũ; đổi nghĩa mà giữ hình dạng (ví dụ query giờ là regex) cũng phải tăng version, và không gì phát hiện được việc đó thay bạn. Adapter là hàm biến implementation hiện tại thành view mà consumer bản cũ mong đợi (guide.md:154–161).
Kiểu của một reference được suy ra từ contract. Với loại remote nhiều variant, nó là một union phân biệt bằng variant: member của variant khác chỉ với tới được sau khi thu hẹp kiểu (contract.ts:431–440).
Host, object, reference và activation#
Host là runtime sở hữu các object của một tiến trình (hay một tab). createHost({ name, self, principalKey }) bắt buộc self: principal mà code của chính host hành động thay mặt. Chord không tự đặt ra principal nào; ứng dụng cung cấp mọi principal (host.ts:109–124). Một facet của plugin gọi host.serve(type, factory, options); factory chạy đồng bộ, khai báo những gì object cần qua self, và trả về implementation (guide.md:163–175).
export interface Self<P = unknown> { /** * A state cell named `name`: its schema (required: there is no TypeScript at runtime) and its initial value * (validated), or `{ init }` to read the first value from its source before the object goes live. The name identifies * the cell across reloads: a reload keeps the cell and its value (`initial` is ignored, `init` does not run), migrates * it when its version rises, and refuses a schema change without a version bump. A cell a reload adds runs its `init` * while the reload prepares. */ state<S extends TSchema>( name: string, schema: S, initial: Static<S> | StateInit<Static<S>, P>, options?: StateOptions<Static<S>>, ): Cell<Static<S>>; /** An object-owned reference, identified by its address. */ get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Ref<C>; /** * A resource named `name`: opened before the object goes live, closed after it is disposed. A reload keeps it unless * its `version`, `deps` or `exclusive` changed. */ resource<T>(name: string, declaration: ResourceDeclaration<T>): Resource<T>; /** * Reacts to `source` once the object is live: a readable (cell, derivation, reference state member) delivers its * current value, then every new value; an event source (or a resource whose value is one) delivers its events. * With `select`, subscribes to `select(value)` instead (the opened value for a resource). Each event runs `handler` * in a fresh root. The name keeps the subscription across reloads while its source and `select` are unchanged; a * listener on state whose source the reload replaced (a derivation is new at every reload) subscribes again and * gets the current value again, so a handler of state must reconcile, not count. */ on<S>(name: string, source: S, handler: (event: EventOf<S>, ctx: Ctx<P>) => void | Promise<void>): void; on<S, E>( name: string, source: S, select: (value: ValueOf<S>) => Subscribable<E>, handler: (event: E, ctx: Ctx<P>) => void | Promise<void>, ): void; /** Long-running work of the live object (a code holder: its calls start fresh roots), restarted at every reload. */ task(fn: (ctx: Ctx<P>) => Promise<void>): void; /** A synchronous projection of readables, recomputed when they change; a new one at every reload. */ derive<const S extends Sources, T>(sources: S, fn: (...values: SourceValues<S>) => T): Readable<T>; /** Objects of lifetime `"host"` only. Keys are the registry's own keys. */ register<V>(registry: Registry<V>, entries: Readonly<Record<string, V>>): void; /** * A member backed by one of this object's resources, for local contracts: references read the resource's value once * the object is live (`undefined` before). Typically an `opaque` member: `{ harness: self.expose(durable) }`. */ expose<T>(resource: Resource<T>): Exposed<T>;}Bảng khai báo của guide nói mỗi thứ sống sót qua reload thế nào (guide.md:203–212). Một quy tắc đi kèm hay bị quên: state cần sống qua reload phải nằm trong cell, không nằm trong biến closure, vì factory chạy lại ở mỗi lần reload (guide.md:222–241).
Lấy một reference: acquire#
Mọi đường (self.get, ctx.get, host.get, host.hold, một frame hold từ link) đều đi qua HostImpl.acquire():
acquire( type: AnyContract, input: unknown, options: GetOptions | undefined, owner: RefOwner, from?: ObjectImpl, candidate = false,): RefCore { const elsewhere = this.#elsewhere(type, input); if (elsewhere !== undefined) return this.#acquireRemote(type, elsewhere.at, elsewhere.input, options, owner); const service = this.#bindingService(type.name, candidate); if (service === undefined) throw unknownType(type.name, this.name); bind(service.contract, service.options.accepts, type); const { address, normalized, principal } = this.#locate(service, input, options?.principal ?? this.principal); let object = this.objects.get(address); if (from !== undefined && object !== undefined && (object === from || reaches(object, from))) { throw invalid("cycle", `${from.address} -> ${address} would create a ref cycle`); } if (object === undefined) { if (this.gating?.has(type.name)) { throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" }); } const dying = this.#dying.get(address); if (dying?.status === "disposed") this.#dying.delete(address); object = new ObjectImpl(this, { address, typeName: type.name, input: normalized, principal, hostLifetime: hostLifetime(service), }); this.objects.set(address, object); // A new object for an address still being disposed waits for that cleanup (exclusive resources). object.start(dying?.status === "disposing" ? dying.dispose(false) : undefined); } const ref = new RefImpl(object, type, owner); object.addRef(ref); return ref;}invalid/placement, bất kể request nói nó đi đâu. Một tham chiếu tạo chu trình trong cùng host bị từ chối ngay ở đây.Địa chỉ của object là tên loại \0 danh tính chính tắc của input \0 phần principal, trong đó principal của chính host (so bằng principalKey) là host (host.ts:515–533). Nên có đúng một object cho mỗi (type, input, principal key): hai tab của cùng chủ sở hữu dùng chung một object, một khách (guest) có object riêng, và code bên trong host luôn hành động như self nên các object nội bộ được chia sẻ (guide.md:356–362).
Activation: factory đồng bộ, việc chậm làm sau#
Factory không được await; mở database hay chờ một object khác là việc của activation, chạy sau khi factory đã khai báo xong:
async #activate(after: Promise<void> | undefined): Promise<void> { await after?.catch(noop); const signal = this.activationAbort.signal; const abandoned = (): ChordError => new ChordError("aborted", `${this.address} abandoned`); if (signal.aborted) throw abandoned(); const service = this.host.service(this.typeName); if (service === undefined) throw new ChordError("unavailable", `${this.typeName} is not served`, { reason: "unknown_type" }); this.service = service; const run = this.run(service, undefined); try { await this.open(run.created.resources, run.created.refs, signal); if (signal.aborted) throw abandoned(); await this.waitHydrated(run); if (signal.aborted) throw abandoned(); await this.initCells(run, signal); if (signal.aborted) throw abandoned(); this.host.checkRegistrations(this, run.decls); this.decls = run.decls; for (const derived of run.created.derives) derived.attach(); this.refreshSchemas(); // Served only by a running reload's pending code version: new code must not run (listeners, tasks, // registrations) before that reload commits. this.deferredRuntime = !this.implementations.has(this.host.currentCodeVersion()); if (!this.deferredRuntime) { if (run.decls.registrations.size > 0) this.host.registriesChanged(this); this.startRuntime(); } // A listener starting up may have released the last reference synchronously: no resurrection. if (signal.aborted) throw abandoned(); this.status = "live";superseded nếu code đổi trong lúc activation. Mọi lỗi ở bất kỳ bước nào gỡ lại toàn bộ: dừng listener và task, đóng resource đã mở theo thứ tự ngược, nhả reference (object.ts:1143–1157).Các bước tương ứng với sơ đồ năm bước của guide (guide.md:249–256): chạy factory (inFactory tăng, nên dùng thứ vừa khai báo trong factory là not_ready), mở resource theo phụ thuộc và chờ reference sẵn sàng trong readyTimeout (mặc định 60 s), chờ mọi state được expose có giá trị, chạy init của các cell khai báo bằng { init }, kiểm registry, gắn derivation, rồi khởi động listener và task. Nhiều get cùng một địa chỉ dùng chung một activation; nếu mọi người nhả trước khi xong, kết quả bị huỷ (guide.md:276–281).
Lifetime, principal và variant#
Object sống khi còn được tham chiếu. lifetime: "referenced" (mặc định) tạo object ở lần get đầu và huỷ sau khi tham chiếu cuối được nhả cộng linger; lifetime: "host" chỉ dành cho loại local có input rỗng, tạo một object duy nhất khi facet nạp và giữ tới khi host đóng, và chỉ object loại này được ghi vào registry (spec.md:135–152). Khi hết tham chiếu và hết lời gọi đang chạy, #maybeIdle() hẹn huỷ (object.ts:1646–1666). Huỷ dừng task, gỡ listener, chờ mọi lời gọi xong, đóng resource theo thứ tự mở ngược, rồi nhả các reference của chính object, có thể kéo theo huỷ object khác (object.ts:1681–1721).
Factory của loại remote chọn một variant theo principal: khách xem được trả về { variant: "viewer", view }, người tham gia nhận thêm send và stop (guide.md:366–381). Variant không phải toàn bộ phân quyền: method vẫn tự kiểm tham số, cờ thay đổi trong lúc giữ object là state, và thu hồi là host.invalidatePrincipal(match), ép huỷ các object khớp ở đây và qua link (guide.md:389–392).
Root, Ctx và một lời gọi method#
Một root là một cây lời gọi ngắn, mở ở điểm vào tin cậy: link nhận một request không mang root, host.root(fn) cho timer và script, mỗi sự kiện giao cho một listener, mỗi lời gọi Chord từ một task (guide.md:495–501). Root chạy trên code version hiện hành lúc nó bắt đầu, xuyên qua các host, cho tới khi kết thúc. Đó là cơ chế để hot reload không cắt ngang công việc đang chạy.
async root<T>(fn: (ctx: Ctx) => T | Promise<T>, options: RootOptions = {}): Promise<T> { // The incarnation keeps root ids unique across restarts of a host with the same name. const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`; this.#local.add(rootId); return this.joinRoot(rootId, options.codeVersion ?? this.current.id, fn, options);}async joinRoot<T>( rootId: string, codeVersionId: number, fn: (ctx: Ctx) => T | Promise<T>, options: { readonly principal?: unknown; readonly signal?: AbortSignal; readonly from?: Connection } = {},): Promise<T> { const codeVersion = this.codeVersions.get(codeVersionId); if (codeVersion === undefined) { this.#local.delete(rootId); throw new ChordError("superseded", `code version ${codeVersionId} is not retained by ${this.name}`); } codeVersion.roots.set(rootId, (codeVersion.roots.get(rootId) ?? 0) + 1); const ctx = new CtxImpl(this, { // Forced retirement aborts what still runs on a code version before closing what only it used. signals: options.signal === undefined ? [codeVersion.abort.signal] : [options.signal, codeVersion.abort.signal], rootId, codeVersion: codeVersion.id, principal: options.principal ?? this.principal, }); try { return await fn(ctx); } finally { ctx.close(); const count = (codeVersion.roots.get(rootId) ?? 1) - 1; if (count > 0) codeVersion.roots.set(rootId, count); else { codeVersion.roots.delete(rootId); if (!this.#foreign.has(rootId)) this.endRoot(rootId); this.maybeRetire(); this.#retirementChanged(); } }}rootId mang một "incarnation" ngẫu nhiên để duy nhất qua các lần khởi động lại của host cùng tên. Code version không còn được giữ thì root bị từ chối superseded (thử lại được). Khi lời gọi cuối của root ở host này xong, host thử retire code version cũ.CtxImpl gộp tín hiệu huỷ của người gọi với tín hiệu của code version, nên một lần retire cưỡng bức huỷ được mọi root còn chạy trên code cũ (object.ts:435–454). Nó cố tình không dùng AbortSignal.any: Node giữ các tín hiệu phụ thuộc còn listener sống theo tín hiệu nguồn sống lâu, nên mỗi ctx sẽ giữ closure (và code) của nó lâu như object (object.ts:485–510; CHANGELOG.md:67). ctx.get() lấy một reference thuộc về lời gọi; close() nhả nó khi lời gọi xong (object.ts:456–482).
Reference là một proxy gắn muộn#
Người gọi không bao giờ cầm implementation. RefImpl lộ ra qua một Proxy: các trường cơ sở (status, ready, variant, release...) đọc thẳng, mọi tên khác đi qua member(name), tra theo contract của consumer và implementation hiện hành (object.ts:789–799). Method trả về một hàm ổn định luôn gọi tới code hiện hành; member state trả về một MemberReadable ổn định, tự đăng ký lại khi reload hay đổi variant đổi nguồn của nó và báo một reset (object.ts:613–618). Gọi không kèm ctx, hay từ một task (code holder), thì mở một root mới (object.ts:944–973).
async call(ref: RefImpl, name: string, args: unknown, ctx: CtxImpl): Promise<unknown> { ctx.check(this.host); if (ref.status === "released" || ref.status === "gone") throw ref.terminalError(); if (ref.status === "loading") await raceSignal(ref.ready, ctx.signal); if (ref.status !== "live" || this.status !== "live") { throw ref.status === "live" ? new ChordError("gone", `${this.address} is ${this.status}`) : ref.terminalError(); } this.admit(ctx); const installed = this.implementations.get(ctx.codeVersion); if (installed === undefined) throw new ChordError( "superseded", `${this.address} has no implementation for code version ${ctx.codeVersion}`, ); const view = ref.viewOf(installed); const variant = variantOf(view); const wanted = ref.describe(ref.contract.scope === "remote" ? variant : LOCAL_VARIANT, name); const fn = view[name]; if (typeof fn !== "function" || (ref.contract.scope === "remote" && wanted?.kind !== "method")) { throw new ChordError( "not_in_variant", `${this.address}: ${name} is not a method of variant ${variant ?? "(local)"}`, ); } let input: unknown; if (takesArgs) { // The additive rule assumes a consumer sends only what its own schema knows, and a provider sees only what its // schema knows: strip with both before validating. const own = ref.contract.scope === "remote" && wanted?.kind === "method" ? wanted.args : undefined; const sent = own === undefined ? args : Value.Clean(own, Value.Clone(args)); input = validated?.args ? Value.Clean(validated.args, Value.Clone(sent)) : sent; if (validated?.args) assertValid("args", `${this.address}.${name} args`, validated.args, input); } this.lease(ctx); const child = ctx.child(this); let result: unknown; try { result = await this.#invoke(() => takesArgs ? (fn as (args: unknown, ctx: Ctx) => unknown)(input, child) : (fn as (ctx: Ctx) => unknown)(child), ); } finally { child.close(); } if (!returns) return undefined; if (validated?.result) assertValid("result", `${this.address}.${name} result`, validated.result, result); return result;admit(ctx) là cổng của mọi lời gọi và mọi lần ghi cell: host đã hỏng thì unavailable/host_failed; code version không còn thì superseded; object đã được nâng cấp ở code version mới hơn thì upgrade_conflict; object đang ở sau cổng (gate) của một reload mà root này chưa từng chạm vào nó thì cũng upgrade_conflict (object.ts:1521–1537). lease(ctx) ghi nhận root đã chạm vào object, để một gate sau đó cho phép nó đi tiếp.
Lỗi#
/** Error codes callers see (guide table). A refused or aborted reload keeps the code of its cause. */export const CHORD_ERROR_CODES = [ "aborted", "contract_mismatch", "forbidden", "gone", "invalid", "not_in_variant", "not_ready", "released", "superseded", "unavailable", "upgrade_conflict", "upgrade_required",] as const;export type ChordErrorCode = (typeof CHORD_ERROR_CODES)[number];const RETRYABLE: ReadonlySet<ChordErrorCode> = new Set(["superseded", "upgrade_conflict"]);export class ChordError extends Error { readonly code: ChordErrorCode; readonly reason: ChordErrorReason | undefined; readonly retryable: boolean; readonly details: unknown; constructor( code: ChordErrorCode, message: string, options?: { cause?: unknown; details?: unknown; reason?: ChordErrorReason }, ) { super(`${code}${options?.reason ? `/${options.reason}` : ""}: ${message}`, { cause: options?.cause, }); this.name = "ChordError"; this.code = code; this.reason = options?.reason; this.retryable = RETRYABLE.has(code); this.details = options?.details; }}ChordError ném từ factory hay từ một phụ thuộc giữ nguyên mã qua mọi bên phụ thuộc; lỗi khác thành unavailable với lỗi gốc làm cause (errors.ts:56–62).Qua link, lỗi đi dưới dạng { code, message, reason } và được dựng lại thành ChordError ở phía nhận (wire.ts:171–181). Bảng nghĩa từng mã nằm trong guide (guide.md:784–799); hai mã đáng nhớ: superseded (code version của root không còn trên host này) và upgrade_conflict (một root cũ chạm vào object vừa được migrate), cả hai thử lại được bằng một root mới.
Hot reload: prepare, gate, commit, retire#
Lời hứa của README: hot reload thay code trong lúc chạy; việc đang bay xong trên code nó đã bắt đầu, state được giữ, và thay đổi không tương thích được migrate ở một điểm an toàn (README.md:9). Spec coi đó là ràng buộc cứng: chỉ khởi động lại host khi chính Chord đổi, hoặc khi phục hồi sau crash (spec.md:8). Bài blog của Armin Ronacher về Pi nhấn mạnh hot reload để agent tự viết và thử extension của mình ("Pi: The Minimal Agent Within OpenClaw"); Chord đưa ý đó lên mức một runtime nhiều tiến trình.
Nạp module mới#
// Node facet loader: `createHost({ loader: nodeLoader })`. Kept out of index.ts so the core stays browser-safe.//// TODO(loader): replace with an isolating, evicting loader. Every reload imports fresh module copies under a new// query and the old copies are never evicted (they leak until the process exits). TUI plugins need isolation, which// this loader does not provide.export const nodeLoader: FacetLoader = async (modules) => { hook(); const version = String(++generation); const facets: Facet[] = []; const urls = modules.map((module) => { const url = toUrl(module); url.search = ""; return url; }); // Every root first: a facet importing another facet's directory gets this load's copy, whatever the order. for (const url of urls) roots.add(new URL(".", url).href); for (const url of urls) { const name = url.href; url.searchParams.set(PARAM, version); // Owner-approved exception to the no-dynamic-import rule; see TODO(loader) above. const loaded = (await import(url.href)) as { default?: FacetDefinition }; const definition = loaded.default; if (definition === undefined || typeof definition.setup !== "function") { throw new TypeError(`${name}: default export must be a facet (defineFacet)`); } facets.push({ name, setup: definition.setup, uses: definition.uses }); } return facets;};registerHooks mang query đó xuống các import tương đối bên trong thư mục của facet, còn Chord và code dùng chung vẫn là một bản duy nhất (loader.ts:17–36). Bản cũ của module không bao giờ được giải phóng.Năm bước#
- LoadChạy
setupcủa các facet mới vào một bảng service ứng viên; tính các loại đã đổi và đã bị gỡ; cấp phát code version kế tiếp ở trạng tháipending(reload.ts:278–303). - PrepareChạy factory mới trên mọi object đang sống của loại đã đổi, ở chế độ ghi lại: đọc được cell, ghi cell thì ném lỗi, listener và task chưa chạy, resource đổi version được mở cạnh bản cũ. Kiểm contract với mọi consumer còn sống. Lỗi bất kỳ: mọi thứ giữ nguyên.
- GateChỉ khi một cell tăng version, hoặc một resource độc quyền (exclusive) bị thay: chặn root cũ mới vào, chờ root đã vào xong, migrate vào bản sao riêng.
- CommitTrong một bước đồng bộ: cài code mới, đổi bảng registry, khởi động lại task, gắn lại listener, gửi
resetvà shape mới cho người quan sát. - RetireCode cũ tiếp tục chạy các root nó đã bắt đầu; khi chúng xong, nó bị bỏ cùng những gì chỉ nó khai báo.
Đây là tóm tắt của guide (guide.md:630–636); code của prepare() đi đúng thứ tự đó:
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */async prepare(): Promise<void> { try { await this.waitForRetirement(); this.load(); this.checkProviders(); await this.prepareAll(); // From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort, // so the candidate set is final once the activations already in flight have been prepared. this.host.gating = this.changed; await this.withDeadline(this.prepareAll(), "activations in flight did not finish"); this.checkRefs(); this.tables = this.host.buildTables(this.entries()); await this.gate(); this.#alive(); this.#stage(); } catch (error) { await this.rollback(); throw reloadError(error); }}prepareAll() chạy hai lần: lần hai sau khi bật gating, để bắt cả các object đang activation dở. Từ lúc đó, tạo object mới của một loại đã đổi bị từ chối upgrade_conflict (thử lại được) cho tới commit hoặc abort.Mỗi cell mang version của riêng nó, và luật được thực thi ngay khi prepare một object:
// Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.for (const [name, cell] of run.decls.cells) { if (object.decls.cells.get(name) !== cell) continue; const from = object.decls.cellVersions.get(name) ?? 1; const to = run.decls.cellVersions.get(name) ?? 1; if (to < from) throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`); if (to > from) candidate.reasons.push(`state ${name} version ${from} -> ${to}`); else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name))) throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);}Gate và migrate#
Bài toán: một root cũ đang giữa chừng đọc state theo hình dạng cũ; nếu migrate ngay dưới chân nó, code cũ sẽ chạy trên dữ liệu mới. Gate giải bằng một lát cắt nhận vào (admission cut): root đã chạm vào object (có lease) được "miễn trừ" (grandfathered) và được vào lại; root cũ chưa chạm thì nhận upgrade_conflict để thử lại như root mới. Task của object bị dừng ở lát cắt.
async gate(): Promise<void> { this.gated = [...this.candidates.values()].filter( (candidate) => candidate.reasons.length > 0 && candidate.object.status === "live", ); const drained = this.gated.map(({ object }) => { const grandfathered = new Set(object.leases.keys()); return new Promise<void>((resolve) => { object.gate = { grandfathered, queue: [], drained: resolve }; object.stopTasks(); if (grandfathered.size === 0) resolve(); }); }); if (timedOut) { const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]); throw new ChordError( "aborted", `gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`, { reason: "gate_deadline", details: { blocking }, }, ); }gateDeadline (mặc định 5 s) thì cả reload bị huỷ và không có gì thay đổi; lỗi mang tên các root đang chặn trong details. Sau đó resource độc quyền được thay (đóng cũ, mở mới, mở lại cũ nếu mới lỗi), rồi migrate(structuredClone(cell.value), from) chạy trên một bản sao riêng và kết quả được kiểm theo schema mới (reload.ts:505–521).Tutorial minh hoạ cả hai trường hợp: đổi nhãn (tương thích) không phải chờ, root cũ đang tạm dừng vẫn thấy "Original" hai lần trong khi lời gọi mới thấy "Updated" (tutorial.md:651–684); đổi nghĩa của rows (viết hoa) với version: 2 và migrate thì đi qua gate, và subscriber nhận snap rồi reset (tutorial.md:694–727).
Commit: mọi thứ có thể lỗi đã xảy ra trước đó#
/** * Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens, * migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs. * Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still * throws, the code version is half-published: the host fails terminally and admits no more work. */commit(beforeStart?: () => void): ReloadReport { try { return holdPublications(() => { const report = this.#publish(); // Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version // starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child // first. beforeStart?.(); for (const start of this.#starts.splice(0)) start(); return report; }); } catch (error) { this.host.fail(error); throw reloadError(error); }}#stage() đăng ký listener và đệm sự kiện). Commit chỉ cài. Nếu một bước cài vẫn ném lỗi, code version bị công bố dở dang: host chuyển sang hỏng vĩnh viễn và từ chối mọi việc mới.#publish() đổi previous thành code version đang chạy và current thành bản mới, cài implementation mới cho các object đã prepare, chép implementation cũ sang id mới cho object không đổi, cài bảng registry, ép huỷ object của loại bị gỡ, và đẩy việc khởi động task cùng tín hiệu facet ra sau (reload.ts:587–656). install() của một object được gate đặt minCodeVersion và bỏ ngay implementation cũ, vì code cũ không bao giờ được chạy trên state đã migrate; cell được migrate nhận reset; reference của trình duyệt mà contract không còn bind được nhận upgrade_required (reload.ts:658–722).
Luật hai phiên bản và retire#
Một host chỉ giữ hai code version: current và previous. Lời gọi mang code version không còn được giữ bị từ chối superseded, không bao giờ được chạy bằng phiên bản khác (spec.md:198). previous được retire khi không còn root nào của nó (và, ở host con, khi cha đã retire nó) (host.ts:701–754). Reload kế tiếp chờ điều đó tối đa retireDeadline; quá hạn thì huỷ các root còn chạy (lý do superseded), cho chúng một giây, rồi retire bất kể (reload.ts:259–276).
Registry cũng theo code version: mỗi code version có một bảng hoàn chỉnh, registry(R).get(key, ctx) đọc bảng của ctx.codeVersion, nên một request bắt đầu trước reload vẫn thấy bảng cũ và không có lúc nào một mục bị thiếu (host.ts:787–809; guide.md:575–578). Spec ghi rõ Chord không kèm cây cầu sang RegistryReader của Durable; ứng dụng tự dựng nó từ bảng (spec.md:230).
Reload cả cụm#
Trên server có host con, host.reload(serverFacets, { remote: { "session:": workerFacets } }) thành một commit hai pha (cluster.ts:7–14): server retire bản trước (frame retire tới con trước prepare nhờ link FIFO), prepare chính nó và gửi prepare cho mọi con đang đồng bộ, gom phiếu (vote) có hạn chót; một lỗi hay một phiếu trễ thì gửi abort mọi nơi; đủ phiếu thì commit ở server, gửi commit cho các con trước khi khởi động bất cứ thứ gì của code mới (cluster.ts:39–115). Một host con kết nối khi đang ở sau code version của cha bị cách ly (fenced) và được hoà giải (reconcile) bằng chính giao thức đó (cluster.ts:117–158). Spec liệt kê giới hạn: quyết định commit không được lưu, nên server crash giữa commit của nó và của con để con ở code cũ cho tới khi kết nối lại; chỉ một tầng con tham gia (spec.md:256–259).
Tiến trình host con chạy dưới createHostManager() của /node: fork với detached để con dẫn một process group và cả cây bị giết khi con thoát, một kênh điều khiển chung IPC với frame Chord (ready, fatal, status, shutdown), và một khoá tiến trình do kernel giữ (abstract socket trên Linux, named pipe trên Windows) mà con lấy trước khi mở storage (manager.ts:76–123; manager.ts:164–229).
Link, wire frame và connection#
Một link là kênh frame hai chiều, có thứ tự, giữa hai host (hoặc một host và một client trình duyệt). Nó không biết gì về object hay state; mọi ngữ nghĩa nằm ở Connection, một đầu của link trên mỗi host.
/** An ordered (FIFO), bidirectional frame channel between two hosts. */export interface Link { readonly closed: boolean; /** * Returns the size of the encoded frame as sent (JSON text length, or bytes of a packed message) when the transport * knows it. The connection's flow control counts these sizes; frames without one count as zero, so a link that never * reports sizes is not windowed. */ send(frame: Frame): number | undefined; /** Sets the single receiver. Frames arriving earlier are queued. */ receive(handler: (frame: Frame) => void): void; onClose(handler: (reason: string) => void): void; /** Closes both ends: the peer gets a `bye`. */ close(reason?: string): void;}send() trả về kích thước frame đã mã hoá khi transport biết nó; flow control đếm các kích thước này, nên một link không báo kích thước (IPC) không bao giờ bị giới hạn cửa sổ.Ba transport đi kèm. memoryLinkPair() cho test, nhưng vẫn dùng đúng giao thức: mỗi frame đi qua một vòng JSON.stringify/parse để tách khỏi bộ nhớ của bên gửi và để lộ những thứ JSON sẽ làm hỏng (như bigint), và giao không đồng bộ theo một chuỗi promise mỗi chiều (link.ts:242–269). ipcLink(process) bọc frame thành { chord2: frame } trên kênh IPC của child_process, để chung chỗ với tin điều khiển (link.ts:296–337). webSocketLink(socket) chạy trên bề mặt WebSocket chuẩn WHATWG, nên Chord không phụ thuộc thư viện ws; frame gửi trước khi socket mở được giữ trong outbox (link.ts:361–412). Phía server, acceptWebSockets() bọc socket ngay (để không mất frame trong lúc xác thực), gọi hàm xác thực của ứng dụng, rồi nhận link như một client với principal đã đóng dấu; không xác thực được thì đóng (websocket.ts:17–35).
Payload lớn (tham số, kết quả, ảnh chụp state có JSON dài hơn 64 KiB ký tự) được deflate. Host chỉ chuyển tiếp frame thì giữ nguyên bytes đã nén, chỉ host dùng payload mới giải nén (link.ts:105–113). Server nên từ chối frame nén từ client không tin cậy, vì một tin nhỏ có thể giải nén ra bất cứ kích thước nào (websocket.ts:24–26).
Frame#
// JSON frames between two hosts (or a host and a client). Ids are chosen by the side that creates the hold, call or// subscription; every frame type has one direction, so ids never collide:// consumer → provider: hold, release, call, cancel, sub, unsub, resync// provider → consumer: shape, gone, result, state// State is addressed by (hold id, member): a hold implies the state of its variant, sent as units (a shape followed by// a snapshot of each member the consumer lacks; see Connection).// both: hello, ping, pong, bye, root_end, ack; parent → child: invalidate, prepare, commit, abort, retire;// child → parent: vote//// Each frame is declared once, as the schema every received frame is checked against (`isFrame`); the types are derived// from it. Payloads (hold inputs, call args, results, snapshots) stay `unknown`: they are validated where they are// used, against the contract. State ops are checked op by op by the delta decoder./** * The link protocol version, announced in `hello`. Peers of different versions do not talk: the side that notices * closes the link with reason `protocol-version <its version>`, so the other side learns it too * (`LinkOptions.onProtocolMismatch`). */export const PROTOCOL_VERSION = 4;isFrame); kiểu TypeScript suy ra từ schema. Payload (input, tham số, kết quả, ảnh chụp) để unknown và được kiểm ở nơi dùng theo contract.Không có frame get riêng: một reference qua link là một hold, được trả lời bằng shape hoặc gone (spec.md:239). Frame hold mang host đích at, token contract của consumer, input đã chuẩn hoá, và có thể principal chuyển tiếp, origin, state: false, again (wire.ts:73–96). State đi trong frame state, đánh địa chỉ bằng id của hold và tên member:
Type.Object({ t: Type.Literal("state"), hold: Id, m: Name, /** `snap`/`reset` without `value`: the value is `undefined`. */ as: Type.Union([Type.Literal("snap"), Type.Literal("reset"), Type.Literal("ops"), Type.Literal("error")]), value: Type.Optional(Type.Unknown()), /** Encoded with the stream's delta encoder; the decoder validates each op. */ ops: Type.Optional(List(List(Type.Unknown()))), error: Type.Optional(WireErrorSchema),}),Hai đầu công bố phiên bản giao thức trong hello; khác phiên bản thì không nói chuyện: bên phát hiện gọi onProtocolMismatch và đóng link với lý do protocol-version <n>, để bên kia cũng biết (connection.ts:832–859). Không thương lượng: bên cũ phải nạp code mới, trình duyệt thì tải lại trang (guide.md:747–753). Frame nhận được mà sai schema, trùng id, hay là frame điều khiển từ sai phía, thì đóng link (connection.ts:665–669).
Principal được đóng dấu, không được khai#
/** * Principal stamping: a downstream link acts as the principal it was accepted with, whatever the frame says. Only * the parent may forward a principal (relayed requests); its own requests act as the principal given to `connect`. */#principalOf(frame: { readonly principal?: unknown }): unknown { if (this.role === "downstream") return this.principal; return frame.principal ?? this.principal;}/** * The client connection a hold comes from: this link's own id for a client link (whatever the frame says), the id * the parent forwarded for a relayed hold, none for a host's own holds. */#originOf(frame: { readonly origin?: string }): string | undefined { return this.role === "downstream" ? this.originId : frame.origin;}Phía provider: unit, feed và encoder theo luồng#
Khi nhận hold, connection kiểm placement của input với tên host mình, gọi acquire() với principal đã đóng dấu, và đăng ký nghe shape của reference (connection.ts:878–929). Khi reference sống, nó gửi một unit: frame shape liệt kê các member state mà consumer còn thiếu, tiếp theo là một frame state as: "snap" cho mỗi member đó (connection.ts:956–983). Mỗi member có một feed: một subscription events() trên member của reference, và một encoder riêng. Sự kiện đến trong lúc ảnh chụp đang được dựng được đệm lại và phát sau ảnh chụp (connection.ts:985–1012). Sau đó:
/** A later event of a member's stream: ops are encoded, a reset starts a new stream. */#feedEvent(served: ServedLocal, name: string, feed: Feed, event: StateEvent<unknown>): void { if (served.feeds.get(name) !== feed) return; const overflow = { resend: () => this.#resend(served, name) }; if (event.type === "ops") { this.sendState( { t: "state", hold: served.id, m: name, as: "ops", ops: feed.encoder.encode(event.ops) }, overflow, ); return; } feed.encoder = encoder(); this.sendState(this.#snapshot(served, name, "reset", event.value), overflow);}Phía consumer: một binding cho mỗi object ở xa#
Mọi reference của một host tới cùng một object ở xa (cùng loại, cùng input, cùng principal key, cùng fingerprint contract) là các view của một RemoteBinding: một hold qua link, một luồng cho mỗi member state, lời gọi đi chung hold đó (host.ts:1084–1109). Binding áp một shape chỉ khi mọi ảnh chụp của nó đã tới, nên một binding live luôn có mọi member state của variant (remote.ts:364–383). Frame state đi vào đây:
/** A state frame for member `name` of the current hold. */state(name: string, frame: StateIn): void { if (this.ended) return; if (frame.as === "error") { const error = fromWireError(frame.error!); // Before the first hydration nobody could read anything: the binding fails instead of waiting forever. if (!this.hydrated && this.stateOn) { this.gone(error); this.close(); } else this.host.report(error); return; } const pending = this.#pending; if (frame.as === "ops") { const target = pending?.values.get(name) ?? this.members.get(name); if (target === undefined) return; const ops = target.decoder.decode(frame.ops ?? []); target.value = applyImmutable(target.value, ops); if (target === this.members.get(name)) this.#deliver(name, { type: "ops", ops, value: target.value }); return; } const fresh: MemberState = { value: frame.value, decoder: decoder() }; if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) { pending.values.set(name, fresh); pending.missing.delete(name); if (pending.missing.size === 0) this.#apply(); return; } if (!this.inVariant(name)) return; this.members.set(name, fresh); this.#deliver(name, { type: "reset", value: fresh.value });}Lời gọi đi như frame call trên hold; client (trình duyệt) không bao giờ mang root hay code version, server mở root cho chúng (connection.ts:700–729). Phía nhận có thể giới hạn số lời gọi đang chạy của một peer (maxCalls); lời gọi vượt trần được trả lời ngay unavailable/too_many_calls và link vẫn mở (connection.ts:1100–1147).
Flow control: chỉ giữ lại state#
* Flow control* ------------** Problem: state is pushed as fast as it changes, but a peer may read slowly (a phone behind a relay), and a relay may* hide that: Cloudflare forwards to browsers without backpressure. Everything sent then queues in front of the next* call result. Example: a browser opens three long transcripts (500 KiB of snapshots each) over a 5 Mbit/s path; a* result sent next waits 2.4 s, and the snapshot of a transcript the user already left is still sent in full.** Solution: the peer acknowledges what it processed (`ack`, a cumulative frame count), and the sender counts the sizes* of the frames not yet acknowledged. While they exceed the window, state waits here in slots, flushed in creation* order as acknowledgements come:* - a member's stream has one slot: ops are appended (encoded batches of one stream may be concatenated, see* `encoder`), a snapshot, reset or error replaces it;* - a hold's unit has one slot, built lazily from the current values when it is sent, so a newer unit merges into it;* while it waits, the members it introduces have no stream yet.** The first unit of a hold that is not a re-hold (`again`) is sent at once, past the window: a `get` waits for it, and* must not wait behind other holds' state. Results, shapes, gone, reload control and pings are sent at once too: they* are bounded by the requests the peer makes. Hence the one ordering exception of a link: while state waits, a result* can arrive before state changes made before it. Frames of one stream stay in order.Cụ thể: hai đầu công bố acks trong hello và gửi frame ack mang số frame đã xử lý cộng dồn, một lần cho mỗi đợt frame đến chứ không mỗi frame (connection.ts:611–619). Khi số byte chưa được xác nhận vượt cửa sổ (mặc định 256 KiB), frame state chờ ở bên gửi trong các ô (slot): mỗi luồng member một ô, ops mới được nối vào, ảnh chụp hay reset thay cả ô (connection.ts:468–506). Nối được là nhờ tính chất của encoder: các lô liên tiếp của cùng một encoder ghép thành một lô được, vì mỗi lô bắt đầu bằng một đường dẫn tường minh (delta/index.ts:547–549). Ops chờ có trần theo link (pendingCap, mặc định bốn cửa sổ); vượt trần thì các luồng cũ nhất bị thay bằng một reset dựng từ giá trị hiện tại lúc xả, nên bộ nhớ bị chặn dù peer dừng bao lâu (connection.ts:508–529). Unit đầu tiên của một hold mới, kết quả, shape, gone và ping thì luôn gửi ngay. Hệ quả duy nhất về thứ tự: trong lúc state chờ, một kết quả có thể tới trước thay đổi state đã xảy ra trước nó.
Relay, route và mất kết nối#
Server chuyển tiếp hold từ trình duyệt tới worker, và giữa các worker. Relay cần một route, vì đó là nơi đặt phân quyền: authorize(principal, hostName) chạy trước khi resolve (có thể sinh tiến trình mới) và chạy lại sau đó; một lần thu hồi principal trong lúc resolve thắng (host.ts:1139–1185; connection.ts:124–129). Link ping mỗi 15 s và đóng sau 45 s im lặng; một link chết nhả mọi thứ giữ qua nó (connection.ts:12–16). Link không tiếp tục được (resume): provider nhả mọi thứ ngay khi link đóng, nên hold lại sau khi kết nối lại là object mới; lời gọi đang bay khi link rớt bị từ chối unavailable và không biết đã chạy hay chưa (spec.md:260). Phía consumer, binding giữ giá trị cuối và trạng thái loading trong lúc hold lại qua link mới (remote.ts:304–314).
Lần theo một thay đổi state qua link#
Kịch bản là ví dụ §6 của tutorial, chạy thật trong test/tutorial.test.ts: một host server phục vụ TodoStore (local, giữ cell rows) và view remote Todos; một host browser kiểu client nối lên qua memoryLinkPair(); trình duyệt lấy reference tới Todos, đăng ký rows, rồi gọi add.
const server = createHost<Principal>({ self: "server", name: "server" });server.serve(TodoStore, todoFactory);serveViews(server);const [down, up] = memoryLinkPair();server.accept(down, { principal: "owner", kind: "client" });const browser = createHost({ self: "browser", name: "browser", kind: "client", upstream: { link: up, principal: "server" },});const todos = await browser.get(Todos, { list: "family" });assert.ok(todos.variant === "editor");assert.deepEqual(todos.rows.value, []);const updated = Promise.withResolvers<void>();const stop = todos.rows.subscribe((rows) => { if (rows.length === 1) updated.resolve();});await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));await updated.promise;assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);Todos trả về rows: store.rows, tức member state của reference tới TodoStore (tutorial.md:355–371); TodoStore.add là rows.change(ctx, (draft) => { draft.push({ text, done: false }); }) (tutorial.md:153–157).Các bước dưới đây là đường đi trong code, đọc theo thứ tự; giá trị cụ thể của phép toán và frame là suy ra từ code, không in ra từ một lần chạy.
- 1. Kết nốiMỗi
Connectiongửihellongay khi được tạo, kèmprotocol: 4vàacks: true(connection.ts:431–438). Server đã quyết định link này làclient, hành động như"owner"; hello của trình duyệt phải khớp. - 2. browser.get(Todos)
acquire()thấy placementhostNamed("server")khác tên host mình, nên tạo mộtRemoteBindingvà giữ nó qua link lên cha (host.ts:1111–1129). Frameholdkhông mang principal: principal của chính host bị lược, và server đằng nào cũng không tin nó. - 3. Server nhận hold
#onHoldkiểm placement, rồiacquire(token, input, { principal: "owner" }, "client"). ObjectTodoschưa có, nên activation chạy factory, factory khai báoself.get(TodoStore), vàTodoschỉ live khiTodoStoređã live. Factory thấy principal không phải"guest"nên chọn varianteditor. - 4. Unit đầu tiênReference live,
#unitgửi ngay (unit đầu không chờ cửa sổ): frameshapevớistate: ["rows"], rồi framestateas: "snap",value: []. Ảnh chụp lấy từ sự kiệnsnapđầu tiên củaevents(), và feed được gắn trong cùng bước nên không lỡ thay đổi nào (connection.ts:989–1005). - 5. Trình duyệt hydrate
shaped()ghi nhận shape đang chờrows; frame snap tới,#apply()cài shape, đặthydrated = true, statuslive;getresolve.todos.rows.subscribe()gọi ngay listener với[]. - 6. Lời gọi add
RemoteBinding.call()làm sạch tham số theo schema của consumer và gửicall; host kiểu client không mang root (connection.ts:724–727). Server mở một root cho nó, hành động như"owner", vàObjectImpl.call()kiểm{ text: "Buy milk" }theo schema của provider. - 7. Draft và phép toán
Todos.addgọistore.addvới ctx của nó.Cell.change()mở draft trên{ v: [] };draft.push(...)sao chép object vào một piece chèn và đánh dấu nút mảng bẩn.prepare(): nút bẩn là mảng nên không đi đường "chỉ object"; đường dẫn của nút là["v"]; kế hoạch có thứ tự cho một piece chèn vào mảng rỗng là["p", ["v"], 0, 0, [{ text: "Buy milk", done: false }]]. - 8. Công bố
#publish()tính lạiopenCount(0 thành 1), rồi trongholdPublicationsadopt, gỡ hộp thành["p", [], 0, 0, [...]]và xếp sự kiệnopsvào hàng publication. Hàng được xả ngay khiholdPublicationstrả về, vẫn trong cùng lời gọi đồng bộ. - 9. Hai lớp MemberReadableSự kiện đi qua
MemberReadable("rows")của reference tớiTodoStore, rồi quaMemberReadable("rows")của reference tớiTodosmà feed đang nghe; mỗi lớp xếp việc giao vào cùng hàng FIFO. - 10. Lên dây
#feedEventmã hoá bằng encoder của luồng; đường dẫn[]dùng lần đầu nên viết thẳng. Cửa sổ chưa đầy nênsendStategửi ngay;MemoryLinkchuyển frame qua một vòng JSON. - 11. Phía trình duyệt
RemoteBinding.state(): decoder của luồng kiểm và dựng lại phép toán,applyImmutable([], ops)đi nhánh append một lần chép, rồiViewReadable.deliver()gọi listener với[{ text: "Buy milk", done: false }]. Trình duyệt hẹn mộtack. - 12. Kết quảMethod của server xong sau đó (một promise), frame
resultkhông mangvaluevìaddkhông khai báo kết quả;browser.root(...)resolve.
Ví dụ minh hoạ: các frame trên link (id, fingerprint và rootId là giả định; ack và ping bị lược)
browser → server {"t":"hello","protocol":4,"name":"browser","kind":"client","codeVersion":1,"acks":true}
server → browser {"t":"hello","protocol":4,"name":"server","kind":"host","codeVersion":1,"acks":true}
browser → server {"t":"hold","id":1,"at":"server","token":{"name":"tutorial.todo-view","scope":"remote",
"version":1,"input":{…},"host":{"name":"server"},"variants":{…}},"input":{"list":"family"}}
server → browser {"t":"shape","id":1,"variant":"editor","members":["rows","add"],"contract":"9f3c…","state":["rows"]}
server → browser {"t":"state","hold":1,"m":"rows","as":"snap","value":[]}
browser → server {"t":"call","id":2,"hold":1,"member":"add","args":{"text":"Buy milk"}}
server: root mới, principal "owner" · Cell.change · ["p",["v"],0,0,[…]] → unbox
server → browser {"t":"state","hold":1,"m":"rows","as":"ops","ops":[["p",[],0,0,[{"text":"Buy milk","done":false}]]]}
server → browser {"t":"result","id":2}
rows.change() còn kết quả đi sau một promise. Khi cửa sổ đầy thì ngược lại có thể xảy ra, nên tutorial chờ updated.promise thay vì đọc state ngay sau khi lời gọi trả về (tutorial.md:827–828).Ba điều kịch bản này cho thấy. Thứ nhất, không bên nào diff lại: phép toán sinh một lần từ draft trên server, được gỡ hộp, mã hoá, giải mã và áp nguyên trạng ở trình duyệt. Thứ hai, danh tính đến từ link: trình duyệt không nói nó là ai, server đóng dấu "owner" lúc accept, và chính principal đó chọn variant editor. Thứ ba, có hai lớp kiểm: tham số được kiểm ở ranh giới provider, còn ảnh chụp và reset được kiểm theo schema mà consumer được hứa trước khi gửi.
Pi Durable dùng Chord thế nào#
Gom mọi câu import từ @earendil-works/chord trong packages/durable/src (51 file) ra được một danh sách ngắn, và nó chỉ chạm tầng nền. Không có createHost, remote, local, link hay reload nào.
| Từ Chord | Nơi Durable dùng | Để làm gì |
|---|---|---|
Context, BACKGROUND_CONTEXT, withAbortSignal, withoutAbortSignal, awaitWithContext, withTelemetry | Tham số cuối của mọi API bất đồng bộ (README.md:78); span của mỗi phase (scheduler.ts:1762–1763) | Huỷ một lần chờ, không huỷ công việc; truyền span xuống phase và tool |
JsonValue, JsonRepresentation, copyJson, Draft | types.ts, harness/tool.ts...; tx.doc() trả về Draft<T> (types.ts:970) | Kiểu JSON nghiêm ngặt, sao chép giá trị vào bản ghi |
track, Tracker, Change, Prepared, Op | session/transaction.ts, session/session.ts | Một tracker cho mỗi document đã nạp; prepare khi callback của commit xong; prepared.ops thành bản ghi delta; adopt sau khi storage ghi xong |
apply, applyImmutable, applyImmutableBatches | Backend SQLite, IndexedDB, Memory; harness/view.ts, harness/task-graph.ts | Đọc base cộng chuỗi delta; áp ops của commit lên view đang mount |
replicatedState, AttachedReplicatedState, ReplicatedStateSource… | session/observation.ts; session.ts:372, view.ts:118, task-graph.ts:116 | documentState(), viewState(), taskGraph() trả về state Chord chỉ đọc |
overlap, OutputBuffer, Progress, boundOutput, PROGRESS_BYTES_PER_SECOND | harness/tool.ts:1–11 | Output có giới hạn của tool đang chạy, nhịp commit tiến độ |
SqliteDatabase, applySqliteMigrations, validateSqliteSchemaPrefix, openNodeSqliteDatabase, DurableObjectSqliteDatabase | storage/sqlite/* | Facade và driver SQLite; Durable vẫn tái xuất các tên cũ (storage/sqlite/node.ts:5–9) |
Document: draft của Chord, commit của Durable#
Mỗi document đã nạp là một Tracker<JsonObject> trong bộ nhớ đệm của Session (transaction.ts:87–98). tx.doc(token) mở tracker.beginChange() và trả về change.state làm draft. Khi callback của commit xong, transaction prepare đồng bộ mọi change đang mở (việc này thu hồi mọi draft), lập kế hoạch ghi: phiên bản định nghĩa đổi thì lưu một base, có phép toán thì lưu một delta { kind: "delta", ops: prepared.ops }, không có gì đổi thì không ghi (transaction.ts:1037–1053). Chỉ sau khi storage ghi thành công, adopt() mới đổi con trỏ; ghi lỗi thì discard() huỷ mọi preparation, tracker giữ revision cũ, và Session chuyển sang hỏng vì lỗi storage là lỗi chết của nó (durable/session/session.ts:747–758). Đây đúng là lý do prepare() và adopt() tách nhau trong tracker: bộ nhớ không bao giờ đi trước storage.
Quan sát: không diff lại ở đâu cả#
Sau mỗi commit, Session đưa giá trị bất biến mới và đúng lô phép toán của commit đó cho các nguồn đang gắn. CommittedStateSource biến chúng thành frame có cursor cho replicatedState(source); CommittedWatch giao (value, ops, context) cho watch(). View của hội thoại ghép phép toán của nhiều document bằng cách dời đường dẫn xuống ["docs", kind] và thêm splice cho entry mới, rồi áp lên giá trị đang mount bằng applyImmutable:
if (change.type !== "document" || change.conversationId !== id) continue; const kind = change.record.kind; if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue; const path: NonEmptyPath = ["docs", kind]; const mounted = mount.docs.get(kind); if (change.value === null) { if (mounted?.id !== change.record.id) continue; mount.docs.delete(kind); docOps.push(["d", path]); } else if (mounted?.id === change.record.id && mounted.version === change.version) { for (const op of change.ops) docOps.push(prefixed(op, path)); } else { mount.docs.set(kind, { id: change.record.id, version: change.version! }); docOps.push(["s", path, change.value]); }}const before = mount.value;const ops = [...docOps, ...entryOps];const frameContext = withoutAbortSignal(context);if (ops.length > 0) { const value = docOps.length === 0 ? mount.value : applyImmutable(mount.value, docOps); mount.value = entries === value.entries ? value : { ...value, entries }; for (const observer of [...mount.observers]) { contained(() => observer.advance?.(mount.value, ops, frameContext), report); }s), document bị retire thì bị xoá (d). Đây là ví dụ mà chương mô hình Durable trích từ spec: ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v].Một ứng dụng muốn đẩy view của Durable cho trình duyệt có hai đường: gói nó trong một object Chord và để link lo phần còn lại (cách guide gợi ý), hoặc tự gửi (value, ops) của watch() qua giao thức của mình và áp bằng applyImmutable ở đầu kia. Repo ở v1.4.0 không chứa ví dụ nào cho đường thứ nhất.
Những mảnh Durable từng sở hữu#
Hai lần chuyển code từ Durable sang Chord cho thấy ranh giới mà nhóm muốn: OutputBuffer và Progress rời Durable sang Chord ở 1.3.0 (CHANGELOG.md:40); các driver SQLite, giao diện và bộ chạy migration chung sang Chord ở 1.4.0, còn helper storage và migration lược đồ của Durable ở lại (durable/CHANGELOG.md:35). Thứ gì không biết gì về hội thoại, entry hay task thì về Chord.
Output, SQLite và telemetry-sqlite#
Ba mảnh còn lại của tầng nền đều là thứ Durable từng tự giữ, hoặc cần chung với ứng dụng.
OutputBuffer và Progress#
Output của một tool hay một tiến trình có thể dài vô hạn, trong khi thứ được lưu và gửi cho mô hình phải có giới hạn. OutputBuffer giữ cửa sổ đầu (head) hoặc cuối (tail) của một luồng với chi phí tỉ lệ với từng chunk: chế độ đầu ngừng lưu khi cửa sổ đã đầy, chế độ cuối bỏ những chunk đầu mà cửa sổ không còn cần, và tổng byte, dòng của cả luồng vẫn được đếm để báo chính xác phần đã bỏ (output.ts:122–217). snapshot() cắt theo dòng nguyên trên ranh giới ký tự UTF-8 và bỏ ký tự điều khiển làm hỏng hiển thị (output.ts:39–59). Phía Durable, harness/tool.ts gán output vào slot như một trường chuỗi duy nhất, để tracker biến một cửa sổ đuôi trượt thành một cặp t + a thay vì ghi lại cả cửa sổ; nó chỉ dùng overlap() để ước số byte một commit sẽ ghi, làm đầu vào cho Progress (durable/harness/tool.ts:493–509).
/** * Adaptive progress commits: the first change after an idle period commits at once; each commit then delays the next * by at least `minIntervalMs` and by its written size at 100 KiB/s. At most one commit is in flight; changes made * meanwhile coalesce into the next one. Paces on `clock`, by default `Date.now` and `setTimeout`. */export class Progress { readonly #write: () => Promise<number>; readonly #onError: (error: unknown) => void; readonly #minIntervalMs: number; readonly #clock: ProgressClock; #waiters: PromiseWithResolvers<void>[] = []; #timer: (() => void) | undefined; #inFlight: Promise<void> | undefined; #nextAt = 0; #dirty = false; #stopped = false; #flush(): void { if (this.#stopped || !this.#dirty) return; this.#dirty = false; const waiters = this.#waiters.splice(0); const started = this.#clock.now(); this.#inFlight = this.#write() .then( (bytes) => { this.#nextAt = started + Math.max(this.#minIntervalMs, (bytes * 1000) / PROGRESS_BYTES_PER_SECOND); for (const waiter of waiters) waiter.resolve(); }, (error: unknown) => { this.#nextAt = started + this.#minIntervalMs; for (const waiter of waiters) waiter.reject(error); this.#onError(error); }, ) .finally(() => { this.#inFlight = undefined; if (this.#dirty) this.#schedule(); }); }write() trả về số byte đã ghi; lần kế sớm nhất là max(minIntervalMs, bytes / 100 KiB/s) sau lúc bắt đầu commit trước. Chỉ một commit bay cùng lúc, thay đổi trong lúc chờ gộp vào commit kế. Chương task của Durable cho thấy nhịp này áp vào output của tool (mặc định 100 ms).SQLite di động#
Một facade bất đồng bộ đủ nhỏ để cài đặt trên node:sqlite (đồng bộ), trên ctx.storage.sql của Durable Object (đồng bộ, không có bước prepare), hay trên một adapter chạy ngoài tiến trình:
/** * Asynchronous SQL operations shared by a database and its transaction handles. * * `exec` runs SQL text without bindings and may contain several statements. `run`, `get`, and `all` * execute one statement with positional bindings. Adapters may cache prepared statements by SQL text, * so callers pass values as bindings instead of interpolating them. */export interface SqliteExecutor { exec(sql: string): Promise<void>; run(sql: string, ...params: SqliteValue[]): Promise<void>; get<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T | undefined>; all<T extends object>(sql: string, ...params: SqliteValue[]): Promise<T[]>;}/** * Minimal portable SQLite database facade. * * All operations are asynchronous so adapters may execute outside the caller's runtime. * * `transaction` passes the callback a transaction handle. All work in the transaction * must use that handle; the handle is invalid after the callback settles. Adapters must * queue unrelated operations and other transactions until the transaction finishes. The * returned promise settles after commit or rollback. Calling the database itself (including * `transaction` or `close`) from inside a callback therefore waits for that transaction and * never settles. * * When the callback rejects, the adapter must roll the transaction back before rejecting * with that same error. If rollback fails, it must reject with a different error (for * example an `AggregateError`) so callers cannot mistake the callback error for a * guaranteed rollback. */export interface SqliteDatabase extends SqliteExecutor { transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T>; close(): Promise<void>;}transaction hay close) từ bên trong callback của transaction sẽ chờ transaction đó và không bao giờ xong. Rollback lỗi thì phải từ chối bằng một lỗi khác lỗi của callback, để người gọi không nhầm là đã rollback chắc chắn.applySqliteMigrations() chạy mọi migration còn thiếu trong một transaction, ghi version vào một bảng metadata một dòng, và từ chối database đã được migrate bởi bản mới hơn (sqlite/migrations.ts:11–43). Adapter Node xếp mọi thao tác vào một hàng nối tiếp, mở transaction bằng BEGIN IMMEDIATE, cache câu lệnh đã prepare theo văn bản SQL, bật WAL với synchronous = NORMAL, chờ khoá tranh chấp 5 s, và wal_checkpoint(TRUNCATE) khi đóng (sqlite/node.ts:130–201). Adapter Durable Object dùng chung một hàng cho mọi adapter trên cùng storage object và từ chối bigint ngoài khoảng an toàn (sqlite/cloudflare.ts:56–75). Cách Durable dựng lược đồ và commit trên facade này nằm ở chương Durable storage.
telemetry-sqlite#
createSqliteTelemetry(db) là một adapter telemetry của pi-telemetry ghi vào chính SQLite của ứng dụng, và cũng là một TelemetryStore đọc lại được. Ghi nhận không chạy SQL: span chỉ được đưa vào hàng đợi khi nó kết thúc, metric được gộp thành count, sum, min, max theo từng khoảng 10 giây cho mỗi chuỗi (tên, loại, đơn vị, thuộc tính) (README.md:117–121).
} finally { settled = true; if (!this.stopped) { if (this.spans.length >= this.capacity) this.droppedSpans++; else this.spans.push([ id, identity.traceId, parent?.id ?? null, name, started, started + performance.now() - clock, JSON.stringify(attributes), JSON.stringify(events), JSON.stringify(links), JSON.stringify(status), ]); } this.db.transaction(async (tx) => { for (const span of spans) await tx.run("INSERT INTO telemetry_spans VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ...span); for (const metric of metrics) await tx.run( `INSERT INTO telemetry_metrics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(interval_ms, series_key) DO UPDATE SET count = count + excluded.count, sum = sum + excluded.sum, min = min(min, excluded.min), max = max(max, excluded.max)`, metric.interval, metric.key, metric.name, metric.kind, metric.unit, metric.attributes, metric.count, metric.sum, metric.min, metric.max, ); const cutoff = Date.now() - this.retention; await tx.run("DELETE FROM telemetry_spans WHERE ended_ms < ?", cutoff); await tx.run("DELETE FROM telemetry_metrics WHERE interval_ms < ?", cutoff); }),maxQueuedSpans, mặc định 10.000); tràn thì tăng droppedSpans. Một lần ghi lỗi dừng ghi nhận vĩnh viễn: flush() và close() từ chối với lỗi gốc, không thử lại (telemetry-sqlite/index.ts:319–326). Hạn giữ mặc định bảy ngày.Truy vấn (findTraces, getTrace, findMetrics) chỉ đọc dòng đã commit, kể cả sau khi adapter đóng mà database còn mở; lọc theo thuộc tính dùng json_each và phân biệt true với số 1 (telemetry-sqlite/store.ts:48–64). Hợp đồng TelemetryStore và cách render một trace thuộc pi-telemetry (chương pi-env, telemetry và evals).
Quyết định thiết kế#
Bất biến bằng hợp đồng, không bằng đóng băng#
Tracker nhận quyền sở hữu gốc mà không duyệt nó, không đóng băng giá trị đã công bố, và để người tiêu thụ trong cùng tiến trình dùng chung container với nơi cung cấp (README.md:145–152). Hai lựa chọn bị bỏ là Object.freeze (phải duyệt cả cây mỗi revision) và sao chép phòng thủ (phải chép mỗi lần đọc). Với state đổi mỗi 100 ms và view chứa cả transcript, cả hai đều tốn đúng phần tracker được thiết kế để tránh. Cái giá là một đột biến trái phép không bị bắt mà làm hỏng authority trong im lặng; vì vậy quy tắc "clone hoặc serialize ở mọi ranh giới tin cậy có thể đột biến" được nhắc ở cả README lẫn delta README. Ngược lại, giá trị đặt qua draft luôn được chép và kiểm, vì đó là chỗ dữ liệu bên ngoài đi vào.
Prepare tách khỏi adopt#
Toàn bộ phần có thể thất bại (sinh phép toán, dựng giá trị) nằm ở prepare(); adopt() là một phép đổi con trỏ không thể lỗi, "so storage failure can discard the candidate without touching authority" (tracker.ts:271–272). Đây là điều kiện để Pi Durable giữ quy tắc "lưu trước, hiển thị sau": giữa prepare và adopt là lần ghi storage. Reload dùng cùng ý ở quy mô lớn hơn: mọi thứ có thể lỗi chạy ở prepare, commit chỉ cài (reload.ts:561–566).
Lô chính xác nhưng không chính tắc, và có trần#
Không đòi một dạng chính tắc cho phép toán giúp tracker chọn đường rẻ: gộp nhiều thay đổi thành một splice vùng, một s ở tổ tiên, hay r. Trần 4.096 phép toán của tracker và phép so chi phí của diffRevisions() bảo đảm một lô không bao giờ đắt hơn nhiều so với ảnh chụp. Đổi lại, người tiêu thụ chỉ được dựa vào giá trị kết quả.
Context tường minh thay vì ngữ cảnh ngầm#
Context là tham số, không phải AsyncLocalStorage. Nó chạy giống nhau trong Node, trình duyệt và Workers, các lời gọi đồng thời giữ cha độc lập, và không có span "hiện tại" ngầm (README.md:91). Cái giá là phải truyền context qua mọi hàm, đúng như mọi API của Durable làm.
Factory đồng bộ, activation bất đồng bộ#
Reload phải chạy lại factory trên object đang sống mà không có hiệu ứng phụ, và cần danh sách khai báo đầy đủ để so với lần trước. Một factory bất đồng bộ không cho được cả hai. Vì vậy factory chỉ khai báo; mở resource, chờ reference và đọc giá trị đầu tiên là việc của activation (spec.md:122–133). Spec ghi rõ những gì đã bị bỏ so với bản guide trước: self.own, holdWhile, factory bất đồng bộ, suy diễn phụ thuộc (spec.md:120).
Root gắn với code version, tối đa hai version#
Một root chạy hết trên code nó bắt đầu, và host không bao giờ thay một code version bằng cái khác (spec.md:198). Giữ hai version đủ để mọi root đang chạy xong mà không giữ code cũ vô hạn; root sống quá lâu thì bị huỷ với superseded ở reload kế tiếp. Đây là lý do guide nhắc "Keep roots short. Long-running work is a task, and long waits are state" (guide.md:501).
Cell có tên, không phải một state duy nhất cho mỗi object#
Mỗi cell có version và migration riêng, nên đổi một cell không buộc migrate cả object. Spec ghi lại một quyết định đã chốt: "giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem" (spec.md:288).
Một hàng publication cho cả tiến trình#
Mọi thông báo (sự kiện state, shape, bảng registry, listener) đi qua một hàng FIFO đồng bộ, nên mọi người quan sát thấy revision theo thứ tự adopt (mục cell). Spec liệt kê cái giá: một observer chậm làm chậm mọi thông báo sau nó trong tiến trình, kể cả của host khác cùng tiến trình (spec.md:262).
Flow control chỉ cho state, xác nhận đầu cuối#
Một relay (Cloudflare tới trình duyệt) có thể che đi một người đọc chậm, nên không thể dựa vào backpressure của transport; xác nhận phải đi từ đầu tới cuối. Kết quả không được chờ sau state, nên link là FIFO trừ việc state đang chờ có thể bị vượt; state đang chờ được gộp thay vì xếp hàng, nên một peer đứng im chỉ tốn bộ nhớ có trần và frame kế tiếp nó nhận là giá trị hiện tại (spec.md:287).
Danh tính đến từ link#
Phía chấp nhận đóng dấu principal từ bộ xác thực của nó; principal được chuyển tiếp chỉ được nhận từ link tới cha; trường principal trong payload bị bỏ qua (spec.md:236). Client không bao giờ mang root hay code version (spec.md:282).
Một ngoại lệ có chủ đích: import động#
Quy tắc của repo cấm import nội tuyến và import động. nodeLoader là ngoại lệ được chủ dự án duyệt, đánh dấu ngay tại dòng gọi (loader.ts:58) và trong ràng buộc cứng của spec, cùng một TODO(loader): thay bằng một loader cô lập và giải phóng được (spec.md:7).
Cạm bẫy và trường hợp biên#
- Sửa giá trị đọc được là làm hỏng state.
cell.value, giá trị trong listener,tracker.value, payload của một lô phép toán đều có thể chia sẻ container với authority; không có gì bị đóng băng (delta/README.md:186–187). - Draft chết theo callback. Dùng draft hay một handle đọc từ nó sau
prepare(),abort()hay sau khi change khác được adopt thì ném lỗi; callback củachange()phải đồng bộ. undefinedcó luật riêng. Gánundefinedcho thuộc tính object là xoá; gán vào phần tử mảng,push(undefined), hayundefinedlồng trong object được đặt vào thì ném lỗi, không bị bỏ qua nhưJSON.stringify. Mảng luôn dày: ghi quá chỉ số kế tiếp hay xoá một phần tử bằngdeletethì ném lỗi (delta/README.md:115–122).- Callback của
cell.change()trả về giá trị là thay giá trị. Đọc từ code: kết quả khácundefinedđược kiểm theo schema rồi gán vào cell (cell.ts:207–212). Vì vậy(d) => d.push(x)trả về độ dài mới và bị từ chối theo schema của mảng; tutorial luôn viết(draft) => { draft.push(x); }.MutableReplicatedState.change()thì bỏ qua giá trị trả về. - Subscriber của
replicatedStatecó thể nhảy cóc. Quá 100 frame chờ thì chỉ giữ frame mới nhất; đọc giá trị được truyền vào callback, vìstate.valuecó thể đã là revision sau (README.md:181–191). - Kết quả có thể tới trước state. Dưới flow control, một lời gọi trả về không có nghĩa thay đổi state nó gây ra đã tới; chờ đúng state bạn cần (guide.md:744).
- Listener trên state phải hoà giải (reconcile), không đếm. Nó nhận giá trị hiện tại khi bắt đầu, và nhận lại khi reload thay nguồn của nó (mọi derivation đều mới ở mỗi reload) (guide.md:218).
- Biến closure mất ở mỗi reload; state cần giữ phải là cell. Một
selectphụ thuộc giá trị bắt được trong closure sẽ không bị phát hiện là đã đổi; đặt tên listener mới khi đó (spec.md:266). - Link không resume. Hold lại sau khi kết nối lại là object mới; lời gọi đang bay khi link rớt không biết đã chạy hay chưa. Một
closecủa resource chờ lời gọi qua một link đã chết sẽ chờ kết nối lại: hãy giới hạn bằng signal (spec.md:267). - Loader Node rò bộ nhớ. Mỗi reload import bản module mới, bản cũ không bao giờ được giải phóng, và plugin không bị cô lập (spec.md:264).
- Hai kiểu tổ hợp tín hiệu huỷ.
CtxImplcủa host tránhAbortSignal.anyvì lý do giữ bộ nhớ đã nói ở trên, cònwithAbortSignal()của/contextvẫn dùng nó (context/index.ts:111–115). Đây là quan sát từ đọc code; repo không có test hay ghi chú nào cho thấy nó gây rò rỉ trong Durable.
Tài liệu lệch code ở v1.4.0#
- Quick start của guide khai báo
Counterbằngremote()mà không cóhost(guide.md:43–52); từ 1.3.0hostlà bắt buộc vàremote()némTypeErrorkhi thiếu (contract.ts:225–226). Bản trong README cóhost: hostNamed("server"). - Bảng khai báo của guide và spec còn ghi
self.get(Type, input, { at? })(guide.md:206; spec.md:104), trong khiGetOptions.atđã bị gỡ ở 1.3.0 (CHANGELOG.md:28);GetOptionschỉ cònprincipalvàstate(types.ts:8–16). - Guide nói "
self.getandself.resourcesonly declare" (guide.md:247);self.resourcesđã bị gỡ ở 1.3.0, API hiện tại làself.resource(name, …)(CHANGELOG.md:24). - Ràng buộc cứng đầu spec nói runtime không phụ thuộc package Pi nào khác (spec.md:6), nhưng
package.jsonphụ thuộc@earendil-works/pi-telemetry(package.json:87–90), được thêm khiContextcó trườngtelemetry(commit 48909d987, 2026-10-18).
Đọc code ở đâu#
Mọi đường dẫn tính từ packages/chord/. Thứ tự dưới đây đi từ tầng nền lên host runtime; nếu chỉ cần phần Durable dùng, dừng sau mục 8.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | README.md | Năm khái niệm, bảng entry point, telemetry trên Context, tracker và mô hình đăng ký của replicatedState |
| 2 | src/context/index.ts | Context, các hàm dẫn xuất, awaitWithContext |
| 3 | src/delta/README.md | Quyền đột biến, vòng đời, bảng phép toán, giới hạn |
| 4 | src/delta/index.ts | Op, WireOp, an toàn đường dẫn, apply, applyImmutable, encoder/decoder |
| 5 | src/delta/tracker.ts | Bắt đầu ở TrackerImpl và ChangeImpl, rồi setProperty, arrayMutators, emitOperations, emitChangedValue |
| 6 | src/delta/diff.ts, apply-immutable-trusted.ts | Diff cấu trúc, áp phép toán tin cậy |
| 7 | src/state.ts | replicatedState: mutable, gắn nguồn, subscriber có trần |
| 8 | src/output.ts, src/sqlite/ | OutputBuffer, Progress, facade và adapter SQLite |
| 9 | docs/tutorial.md | Danh sách todo dựng dần; mỗi ví dụ chạy trong test/tutorial.test.ts |
| 10 | src/cell.ts, src/publish.ts, src/types.ts | Cell, derivation, hàng publication, Readable, Ctx |
| 11 | src/contract.ts, src/schema.ts | remote/local, placement, coverage, bind, fits, danh tính input |
| 12 | src/object.ts | Self, CtxImpl, RefImpl, ObjectImpl (activation, call, dispose) |
| 13 | src/host.ts | acquire, root, registry, accept/connect/route, relay, close |
| 14 | src/reload.ts, src/cluster.ts, src/loader.ts | Prepare, gate, commit, retire; reload cả cụm; loader Node |
| 15 | src/wire.ts, src/link.ts, src/websocket.ts | Frame, ba transport, nén payload |
| 16 | src/connection.ts, src/remote.ts | Unit, feed, flow control, relay; binding và view phía consumer |
| 17 | src/telemetry-sqlite/, src/manager.ts | Ghi telemetry vào SQLite; quản lý tiến trình host con |
| 18 | docs/guide.md, docs/spec.md | Tham chiếu API; ngữ nghĩa và §12 giới hạn, quyết định (spec nói: lệch với code là bug) |
| 19 | test/delta-tracker/, test/link.test.ts, test/reload.test.ts | Hành vi biên của tracker, link và reload, ở dạng có thể chạy |
Tóm tắt#
- Chord là host runtime có kiểu cho plugin nạp lại nóng, contract, state nhân bản và RPC qua link JSON; bên dưới là các mảnh dùng độc lập: Context, delta,
replicatedState, output, SQLite. - Ở v1.4.0 chỉ Pi Durable và pi-env phụ thuộc Chord, và chỉ dùng tầng nền; không package nào khác trong repo tạo host.
Contextmang huỷ, span telemetry và giá trị có khoá, truyền tường minh; huỷ một lần chờ không huỷ công việc.Ctxcủa host runtime là thứ khác.- Thay đổi là lô bảy phép toán (
r s d a t p m), chính xác nhưng không chính tắc; trên dây, đường dẫn được đánh mã từ lần dùng thứ hai vàrlà điểm khôi phục. - Tracker: draft proxy,
prepare()sinh phép toán và revision mới mà không đổi authority,adopt()là phép đổi con trỏ; đó là móc để Durable ghi storage trước khi hiển thị. - Host runtime: contract là giá trị lúc chạy với luật bind theo version và "phủ"; object tại địa chỉ
(type, input, principal), chỉ chạm tới qua reference gắn muộn; factory đồng bộ, activation bất đồng bộ. - Hot reload: load, prepare không hiệu ứng phụ, gate khi state đổi nghĩa, commit đồng bộ, retire theo luật hai phiên bản; root chạy hết trên code nó bắt đầu.
- Link chở frame JSON đã kiểm schema; principal do phía chấp nhận đóng dấu; state đi thành unit rồi ops theo encoder của từng luồng, có flow control chỉ cho state.