@earendil-works/pi-durable 0.0.1 → 0.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ # Pico5 documents through Chord
2
+
3
+ This guide uses the contracts in the [Pico5 specification](pico-v5.md).
4
+
5
+ - **Session:** a durable container for conversations, entries, tasks, and documents;
6
+ it serializes mutations on one commit line.
7
+ - **Facet:** a feature's setup unit in a Chord host. Setup declares services and
8
+ dependencies synchronously; `onActivate` runs after dependencies are ready.
9
+ - **Context:** explicitly passed cancellation and invocation values. Forward the caller's context.
10
+ - **Service:** a typed token and implementation acquired with `env.use`. Remote
11
+ services contain replicated state and async methods with JSON arguments plus a trailing `Context`.
12
+ - **Durable document:** JSON persisted as a complete base, Chord operations, and
13
+ checkpoints. Its definition token declares schema, version, scope, and initialization;
14
+ conversation scope also declares history and fork behavior.
15
+ - **Transaction draft:** the mutable tracked object from `await tx.doc`, valid
16
+ only inside that commit callback, including all nested objects.
17
+ - **DocumentSource:** an opaque handle to one document incarnation's committed changes,
18
+ not a mutable value or a public subscription API.
19
+ - **ReplicatedState:** Chord's immutable complete values through `value` and
20
+ `subscribe(listener)`. `value` is `undefined` before hydration or while disconnected.
21
+ The listener receives `(value, context, delivery)`; delivery contains `kind`
22
+ (`hydrate` or `update`) and a stream `sequence`, not a Session commit number.
23
+
24
+ Documents are scoped directly to a Session, conversation, or task. A task is
25
+ durable work attached to a conversation; its documents retire when it becomes
26
+ terminal and never participate in conversation forks.
27
+
28
+ ## Imports and the adapter boundary
29
+
30
+ Examples build on one another. Pico5 names (`defineDoc`, `defineDocFamily`,
31
+ `Session`, `DocumentSource`, `Id`, `TaskRuntime`, `DocumentObserver`)
32
+ refer to normative contracts, without a specified import path or runnable Pico5
33
+ package. In those contracts, `ConversationRecord`, `EntryRecord`, and
34
+ `TaskRecord` are persisted records, while `Conversation` is the public
35
+ conversation object and `Entry`/`Task` are typed definitions. These Chord imports
36
+ exist today:
37
+
38
+ ```ts
39
+ import {
40
+ createFacetHost, createRemoteServiceBinding, defineFacet, defineService,
41
+ type Context, type Facet, type JsonValue, type RemoteServiceTransport,
42
+ type ReplicatedState,
43
+ } from "@earendil-works/chord";
44
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
45
+ import { applyImmutable } from "@earendil-works/chord/delta";
46
+
47
+ // The spec calls this JsonObject; current Chord exports JsonValue, not JsonObject.
48
+ type JsonObject = { [key: string]: JsonValue };
49
+
50
+ // Required by Pico5, but not a current Chord export yet.
51
+ declare function documentReplicatedState<T extends JsonObject>(
52
+ source: DocumentSource<T>, context: Context,
53
+ ): Promise<{ readonly state: ReplicatedState<T | null>; dispose(): void }>;
54
+ ```
55
+
56
+ The adapter must register Chord-recognized state, atomically hydrate a matching
57
+ committed value and adapter-owned delivery sequence, and forward committed ops without another
58
+ tracker or re-diff. Disposal releases observation, not the document. Even Chord's
59
+ hydration requests must never flush an uncommitted draft.
60
+
61
+ Current `env.replicatedState(initial)` creates a **mutable, in-memory** tracker
62
+ with `.state` and `.publish(context)`, not a durable source adapter. There is no
63
+ `replicatedState.own(source)`. Do not mirror documents into another tracker.
64
+
65
+ ## 1. A Session-wide canvas
66
+
67
+ All conversations in this Session share one canvas. Session documents are
68
+ current-only and have no history or fork settings.
69
+
70
+ ```ts
71
+ type Stroke = { color: string; points: { x: number; y: number }[] };
72
+ type CanvasState = { strokes: Stroke[] };
73
+
74
+ const CanvasDoc = defineDoc<CanvasState>({
75
+ kind: "app.canvas", version: 1, scope: "session",
76
+ initial: () => ({ strokes: [] }),
77
+ // This append-only example adds one stroke per commit.
78
+ checkpointWhen: value => value.strokes.length % 100 === 0,
79
+ });
80
+
81
+ interface CanvasService {
82
+ readonly state: ReplicatedState<CanvasState | null>;
83
+ addStroke(stroke: Stroke, context: Context): Promise<void>;
84
+ }
85
+ const Canvas = defineService<CanvasService>("app.canvas");
86
+
87
+ async function createCanvasFacet(session: Session, context: Context): Promise<Facet> {
88
+ const target = { scope: "session" } as const;
89
+ // Get-or-create: an absent canvas is initialized in a serialized commit.
90
+ const source = await session.documentSource(CanvasDoc, target, context);
91
+ const replica = await documentReplicatedState(source, context);
92
+ return defineFacet({
93
+ id: "app.canvas/session",
94
+ setup(env) {
95
+ env.own(() => replica.dispose());
96
+ env.provide(Canvas, {
97
+ state: replica.state,
98
+ async addStroke(stroke, context) {
99
+ await session.commit(async tx => {
100
+ const draft = await tx.doc(CanvasDoc, target);
101
+ draft.strokes.push(stroke); // The draft now owns stroke; caller must not mutate it.
102
+ }, context);
103
+ },
104
+ });
105
+ },
106
+ });
107
+ }
108
+
109
+ const CanvasConsumer = defineFacet({
110
+ id: "app.canvas/consumer",
111
+ setup(env) {
112
+ const canvas = env.use(Canvas); // Declare now; access only after activation.
113
+ env.onActivate(() => {
114
+ env.own(canvas.state.subscribe((value, _context, delivery) => {
115
+ console.log(delivery.kind, delivery.sequence, value?.strokes.length ?? "retired");
116
+ })); // subscribe delivers the current hydrated value, then updates.
117
+ });
118
+ },
119
+ });
120
+
121
+ async function runCanvasExample(session: Session): Promise<void> {
122
+ const context = BACKGROUND_CONTEXT;
123
+ const provider = await createCanvasFacet(session, context);
124
+ const host = await createFacetHost({ facets: [provider, CanvasConsumer] });
125
+ try {
126
+ await host.services.use(Canvas).addStroke({
127
+ color: "black", points: [{ x: 10, y: 20 }, { x: 30, y: 40 }],
128
+ }, context);
129
+ } finally {
130
+ await host.dispose(); // Unsubscribes; does not delete the canvas or close Session.
131
+ }
132
+ }
133
+ ```
134
+
135
+ The application owns the open Session. Acquire state **before** synchronous
136
+ `setup`; `env.provide` cannot run in `onActivate`. Install one provider per
137
+ Session host. Chord may reload presentation facets independently, but v1 does
138
+ not use facet reload to replace Session-side task or hook implementations. A
139
+ host extension code change closes and reopens the Harness with the new
140
+ definition set.
141
+
142
+ Remote clients supply a `RemoteServiceTransport` connected to the host's `services`
143
+ provider; Chord prescribes no socket protocol. `CanvasConsumer` also works unchanged
144
+ in a UI host configured with a remote service source.
145
+
146
+ ```ts
147
+ async function connectCanvas(transport: RemoteServiceTransport, context: Context) {
148
+ const services = createRemoteServiceBinding({ services: [Canvas], transport });
149
+ const canvas = services.use(Canvas);
150
+ const stop = canvas.state.subscribe((value, _context, delivery) => {
151
+ console.log(delivery.kind, delivery.sequence, value?.strokes ?? "retired");
152
+ });
153
+ try {
154
+ await services.ready(context); // Initial snapshot installed; not all future updates.
155
+ } catch (error) {
156
+ stop();
157
+ await services.dispose(BACKGROUND_CONTEXT);
158
+ throw error;
159
+ }
160
+ return async () => {
161
+ stop();
162
+ await services.dispose(BACKGROUND_CONTEXT);
163
+ };
164
+ }
165
+ ```
166
+
167
+ ```text
168
+ first acquisition -> commit base { strokes: [] }
169
+ addStroke(A) -> commit A -> local and remote subscribers see A
170
+ worker restarts -> open same durable storage; install canvas facet again
171
+ acquire source -> load base + committed deltas, without rerunning initial()
172
+ late client -> hydrate complete canvas including A, then ordered updates
173
+ conversation fork -> still uses this same Session canvas
174
+ ```
175
+
176
+ Use persistent storage for restart survival. The memory backend is not persistent.
177
+ A method's successful commit does not promise every remote callback has run yet.
178
+
179
+ ## 2. Conversation-scoped diff reviews
180
+
181
+ A **document family** uses one definition for many instances. The logical key is
182
+ `(kind, conversationId, key)`; the persisted numeric document ID identifies an
183
+ incarnation and is never reused. Initializer input is not part of the key.
184
+
185
+ ```ts
186
+ type ReviewInput = { path: string; patch: string };
187
+ type ReviewComment = { id: string; line: number; text: string };
188
+ type ReviewState = ReviewInput & { comments: ReviewComment[] };
189
+ const ReviewDoc = defineDocFamily<ReviewState, ReviewInput>({
190
+ kind: "app.diff-review", version: 1, family: true, scope: "conversation",
191
+ history: "latest", fork: "current",
192
+ initial: input => ({ path: input.path, patch: input.patch, comments: [] }),
193
+ checkpointWhen: value => value.comments.length % 50 === 0,
194
+ });
195
+ interface DiffReviewService {
196
+ readonly state: ReplicatedState<ReviewState | null>;
197
+ identity(context: Context): Promise<{ conversationId: Id; key: string }>;
198
+ addComment(comment: ReviewComment, context: Context): Promise<void>;
199
+ }
200
+ const DiffReviews = defineService<DiffReviewService>("app.diff-reviews");
201
+
202
+ function reviewFacet(
203
+ session: Session, conversationId: Id,
204
+ reviews: readonly { key: string; initial: ReviewInput }[], context: Context,
205
+ ): Facet {
206
+ return defineFacet({
207
+ id: "app.diff-reviews/session",
208
+ setup(env) {
209
+ const instances = env.provideMany(DiffReviews);
210
+ env.onActivate(async () => {
211
+ for (const review of reviews) {
212
+ const target = { scope: "conversation" as const, conversationId, ...review };
213
+ const source = await session.documentSource(ReviewDoc, target, context);
214
+ const replica = await documentReplicatedState(source, context);
215
+ env.own(() => replica.dispose());
216
+ // Chord instance keys route services; they are not numeric document incarnation IDs.
217
+ instances.spawn(JSON.stringify([conversationId, review.key]), {
218
+ state: replica.state,
219
+ async identity() { return { conversationId, key: review.key }; },
220
+ async addComment(comment, context) {
221
+ await session.commit(async tx => {
222
+ const draft = await tx.doc(ReviewDoc, target);
223
+ draft.comments.push(comment); // Do not mutate comment after this assignment.
224
+ }, context);
225
+ },
226
+ }); // The facet owns spawned service lifetimes automatically.
227
+ }
228
+ });
229
+ },
230
+ });
231
+ }
232
+ ```
233
+
234
+ Example input: `[{ key: "review-7", initial: { path: "a.ts", patch: "-old\n+new" } }]`.
235
+ Pass distinct family keys and a real conversation ID; install the facet as above.
236
+ Keyed consumers use `env.observe(DiffReviews, handler)`, not `env.use`. The handler
237
+ receives `(review, context)`; call `review.identity(context)` to identify it and
238
+ `review.state.subscribe` to observe comments.
239
+
240
+ These comments are conversation-scoped: ending the generating task or unloading
241
+ the facet does not retire them. `current` copies every logically
242
+ present review at fork-commit time into independent child instances with new
243
+ numeric IDs, retaining family keys. Parent and child then diverge.
244
+
245
+ ```text
246
+ parent review-7: comment A -> transcript entry E -> comment B
247
+ fork at E with current: child review-7 contains A and B
248
+ child adds C: parent still contains only A and B
249
+ ```
250
+
251
+ With `fork: "initial"`, no instance copies; first child access uses supplied input.
252
+ `latest` cannot read history or fork `asOf`; use `history: "rewindable"` with `asOf`
253
+ to reflect E's commit. Reaccess ignores `initial`; updating the patch requires
254
+ an explicit mutation or a distinct review key.
255
+
256
+ ## 3. Task-scoped output and a tool/task watch
257
+
258
+ This is a bounded progress window, not the permanent result. Scope itself gives
259
+ it task lifetime; no history, fork, owner, or conversation setting is needed.
260
+
261
+ ```ts
262
+ type JobInput = { command: string };
263
+ type JobOutput = { stdout: string; chunks: number };
264
+ const JobOutputDoc = defineDoc<JobOutput>({
265
+ kind: "app.job-output", version: 1, scope: "task",
266
+ initial: () => ({ stdout: "", chunks: 0 }),
267
+ checkpointWhen: value => value.chunks % 100 === 0,
268
+ });
269
+ function jobOutputTarget(taskId: Id) {
270
+ return { scope: "task" as const, taskId };
271
+ }
272
+ async function appendJobOutput(
273
+ runtime: TaskRuntime<JobInput, { phase: "running" }, null, {}>,
274
+ chunk: string, context: Context,
275
+ ): Promise<void> {
276
+ // Read process output outside this callback. The runtime gates the live task.
277
+ await runtime.commit(async tx => {
278
+ const draft = await tx.doc(JobOutputDoc, jobOutputTarget(runtime.taskId));
279
+ draft.stdout = (draft.stdout + chunk).slice(-50_000);
280
+ draft.chunks += 1;
281
+ }, context);
282
+ }
283
+ async function observeJob(
284
+ api: DocumentObserver, producerTaskId: Id,
285
+ finished: Promise<void>, context: Context,
286
+ ): Promise<void> {
287
+ const target = jobOutputTarget(producerTaskId);
288
+ const watch = await api.watchDoc(JobOutputDoc, target, context);
289
+ let value = watch.value;
290
+ console.log(value === null ? "retired" : value.stdout);
291
+ try {
292
+ watch.start(async (ops, _context) => {
293
+ value = applyImmutable(value, ops);
294
+ await render(value === null ? "retired" : value.stdout);
295
+ }); // Serialized callbacks never overlap.
296
+ await finished; // Caller-supplied observation lifetime; outside any commit.
297
+ } finally {
298
+ watch.stop(); // Idempotent; prevents another callback from starting.
299
+ await watch.closed; // Wait for an in-flight callback to settle.
300
+ }
301
+ }
302
+ ```
303
+
304
+ The running phase calls `appendJobOutput` and must also commit its next checkpoint
305
+ or terminal outcome. `api` is the invocation's `DocumentObserver`; `TaskRuntime`
306
+ includes that interface. Acquisition validates the target task is live and
307
+ derives its conversation from the task record.
308
+
309
+ ```text
310
+ watchDoc: capture immutable V0 + register for later committed operation batches
311
+ producer commits D1 and D2 before start: queue D1, D2
312
+ pending operation count exceeds its limit: replace suffix with [["r", V2]]
313
+ start: caller has initialized from V0; deliver the reset through the async listener
314
+ producer commits D3 while listener awaits: queue D3; never overlap callbacks
315
+ unrelated commit has no source ops: enqueue nothing
316
+ producer becomes terminal: queue [["r", null]], deliver it, then close as retired
317
+ ```
318
+
319
+ Watches automatically stop when their invocation ends. A Session-acquired watch
320
+ is caller-owned and stops on Session close. Previously delivered snapshots never
321
+ mutate. Slow or unstarted delivery may coalesce an undelivered suffix into a
322
+ complete reset, so a watch is convergent state observation rather than a
323
+ transition journal. Retirement ends the incarnation's stream; recreation
324
+ requires a new watch/source and a new numeric document ID. A terminal task cannot
325
+ create more output. Task-scoped documents
326
+ never copy into forks. Preserve required output in result entries or Session- or
327
+ conversation-scoped documents in the terminal commit before retirement. Use a
328
+ task-scoped family only when one task needs several independently keyed documents.
329
+
330
+ ## 4. Built-in conversation views and commit delivery
331
+
332
+ `ConversationView` contains `conversation`, raw transcript `entries`, and
333
+ `docs: Readonly<Record<string, JsonObject>>`. Selected built-in singletons mount
334
+ under `docs` by stable document kind, not numeric incarnation ID. The spec's
335
+ illustrative path mapping is:
336
+
337
+ ```text
338
+ document ["s", ["message"], value]
339
+ -> view ["s", ["docs", "pi.live", "message"], value]
340
+ ```
341
+
342
+ Built-in kinds and fields await approval; `pi.live` is illustrative, not an available API.
343
+ The mount publishes one batch per complete Session commit: entry/head changes and
344
+ changed mounted documents together, without a tracker or semantic projection.
345
+ Third-party documents are **not automatically mounted**; use their own Chord
346
+ services or trusted invocation watches. A retired source publishes `null`; a
347
+ dynamic service should then withdraw its instance rather than expose stale data.
348
+
349
+ ```text
350
+ addStroke -> hold Session mutation line -> await tx.doc -> mutate tracked draft
351
+ callback succeeds -> tracker flush: incremental ops + candidate value
352
+ Session checkpoint predicate selects a base or delta exactly once
353
+ atomic storage commit: persist selected document and record writes
354
+ storage succeeds -> materialize immutable published value + enqueue value/ops, still on line
355
+ release line -> deliver committed source ops -> adapter -> local/remote Chord consumers
356
+ late subscriber -> atomically capture committed value + adapter sequence + subscription
357
+ ```
358
+
359
+ No visible-undurable path exists. Callback failure restores the unchanged
360
+ baseline. Failure after flush, including checkpoint/storage failure, publishes
361
+ nothing and poisons the open Session; close and reopen it instead of continuing.
362
+
363
+ ## Footguns
364
+
365
+ - Never retain drafts, nested proxies, or bound array methods across commits.
366
+ Never mutate inserted aliases or insert one mutable object at multiple paths.
367
+ - Async commit holds the line through storage settlement and baseline adoption.
368
+ Await document access there, not models, processes, network calls, or humans.
369
+ - Normal `snapshot`, `documentSource`, and `watchDoc` reads are get-or-create and
370
+ lazily migrate through the supplied token. Harness open does not scan ordinary
371
+ documents. Family `initial` is first-creation input, not an update.
372
+ - Initialize from the fixed `watch.value` before `start()`. Slow or unstarted
373
+ delivery may coalesce an undelivered suffix into a root replacement when its
374
+ operation count exceeds the limit, omitting intermediate states. Queue
375
+ accounting never serializes operations to estimate bytes. Do not use a watch
376
+ as an audit log.
377
+ - A listener may call `stop()`, but must not await its own `closed` promise.
378
+ - Definitions own checkpoints, not storage heuristics. Revise the counting predicates
379
+ above if mutations change. Keep document kinds, versions, fork policies, and
380
+ public paths stable; schema changes require migration, not a source-only rename.
381
+
382
+ Chord sources: [types](../../chord/src/types.ts), [facet examples](../../chord/test/facets.test.ts),
383
+ [state](../../chord/src/services/state.ts), [provider](../../chord/src/services/provider.ts),
384
+ [Context](../../chord/src/context/index.ts), [Delta](../../chord/src/delta/README.md).