@earendil-works/chord 1.2.0 → 1.3.0
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.
- package/CHANGELOG.md +58 -0
- package/README.md +14 -13
- package/dist/cell.d.ts +30 -9
- package/dist/cell.d.ts.map +1 -1
- package/dist/cell.js +52 -12
- package/dist/cell.js.map +1 -1
- package/dist/cluster.d.ts +4 -3
- package/dist/cluster.d.ts.map +1 -1
- package/dist/cluster.js +58 -42
- package/dist/cluster.js.map +1 -1
- package/dist/connection.d.ts +171 -58
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +676 -217
- package/dist/connection.js.map +1 -1
- package/dist/contract.d.ts +120 -45
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +104 -15
- package/dist/contract.js.map +1 -1
- package/dist/declare.d.ts +11 -48
- package/dist/declare.d.ts.map +1 -1
- package/dist/declare.js +1 -21
- package/dist/declare.js.map +1 -1
- package/dist/delta/index.d.ts +2 -1
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +4 -0
- package/dist/delta/index.js.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/host.d.ts +214 -120
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +446 -241
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +47 -12
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +200 -14
- package/dist/link.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +7 -2
- package/dist/loader.js.map +1 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +35 -9
- package/dist/manager.js.map +1 -1
- package/dist/object.d.ts +438 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/{record.js → object.js} +614 -335
- package/dist/object.js.map +1 -0
- package/dist/output.d.ts +85 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +350 -0
- package/dist/output.js.map +1 -0
- package/dist/registry.d.ts +3 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/reload.d.ts +35 -27
- package/dist/reload.d.ts.map +1 -1
- package/dist/reload.js +256 -192
- package/dist/reload.js.map +1 -1
- package/dist/remote.d.ts +130 -35
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +493 -189
- package/dist/remote.js.map +1 -1
- package/dist/types.d.ts +39 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/websocket.d.ts +4 -5
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +3 -2
- package/dist/websocket.js.map +1 -1
- package/dist/wire.d.ts +210 -129
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +108 -55
- package/dist/wire.js.map +1 -1
- package/docs/guide.md +267 -203
- package/docs/spec.md +141 -114
- package/docs/tutorial.md +257 -235
- package/package.json +6 -1
- package/dist/record.d.ts +0 -320
- package/dist/record.d.ts.map +0 -1
- package/dist/record.js.map +0 -1
package/docs/tutorial.md
CHANGED
|
@@ -7,15 +7,15 @@ You could hand-write object creation, subscription cleanup, messages between
|
|
|
7
7
|
processes, and code replacement. Chord connects these pieces for you.
|
|
8
8
|
|
|
9
9
|
A **host** is the runtime that owns objects. An **object** combines state with
|
|
10
|
-
methods at an address, such as “the family todo list.” A **
|
|
11
|
-
reference to that object, not the object itself. Several
|
|
12
|
-
same object.
|
|
10
|
+
methods at an address, such as “the family todo list.” A **reference** is a caller's
|
|
11
|
+
reference to that object, not the object itself. Several references can reach the
|
|
12
|
+
same object. References can also reach another host over a connection.
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
15
|
browser host server host
|
|
16
|
-
|
|
16
|
+
ref + subscribed rows -- link --> Todos view (owner or guest)
|
|
17
17
|
|
|
|
18
|
-
|
|
18
|
+
ref
|
|
19
19
|
v
|
|
20
20
|
TodoStore: rows + methods
|
|
21
21
|
```
|
|
@@ -27,7 +27,7 @@ asking for it again.
|
|
|
27
27
|
|
|
28
28
|
<!-- example:01 -->
|
|
29
29
|
```ts
|
|
30
|
-
const host = createHost({ name: "todo-server" });
|
|
30
|
+
const host = createHost({ self: "server", name: "todo-server" });
|
|
31
31
|
assert.equal(host.name, "todo-server");
|
|
32
32
|
await host.close();
|
|
33
33
|
```
|
|
@@ -46,7 +46,10 @@ shuts down its objects and connections.
|
|
|
46
46
|
Before callers can use an object, they need to agree on its name and members.
|
|
47
47
|
That agreement is a **contract**: a runtime value that also gives TypeScript its
|
|
48
48
|
types. A **schema** describes a value's shape. We use TypeBox's `Type` to write
|
|
49
|
-
schemas
|
|
49
|
+
schemas. `method(args, result)` declares a method by its argument and result
|
|
50
|
+
schemas: `method()` takes no arguments and returns nothing, and
|
|
51
|
+
`method(undefined, Type.String())`, used below, takes no arguments and returns a
|
|
52
|
+
string.
|
|
50
53
|
|
|
51
54
|
Start with a label for a named list. `local` means only code inside this host
|
|
52
55
|
can reach it. The input `{ list: "family" }` identifies which list we want;
|
|
@@ -57,11 +60,11 @@ it is not an argument to `label`.
|
|
|
57
60
|
const TodoLabel = local("tutorial.todos", {
|
|
58
61
|
version: 1,
|
|
59
62
|
input: Type.Object({ list: Type.String() }),
|
|
60
|
-
members: { label: method(
|
|
63
|
+
members: { label: method(undefined, Type.String()) },
|
|
61
64
|
});
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
A **factory** is a synchronous function that creates the object's
|
|
67
|
+
A **factory** is a synchronous function that creates the object's implementation:
|
|
65
68
|
the methods and state callers will see. `serve` registers a contract and its
|
|
66
69
|
factory. Registration alone does not activate this lazy object; the first
|
|
67
70
|
`get` does.
|
|
@@ -73,26 +76,27 @@ call from that root.
|
|
|
73
76
|
|
|
74
77
|
<!-- example:02-call -->
|
|
75
78
|
```ts
|
|
76
|
-
const host = createHost();
|
|
79
|
+
const host = createHost({ self: "server" });
|
|
77
80
|
host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` }));
|
|
78
81
|
const result = await host.root(async (ctx) => {
|
|
79
|
-
const todos = ctx.get(TodoLabel, { list: "family" });
|
|
80
|
-
return todos.label(
|
|
82
|
+
const todos = await ctx.get(TodoLabel, { list: "family" });
|
|
83
|
+
return todos.label(ctx);
|
|
81
84
|
});
|
|
82
85
|
assert.equal(result, "Todos for family");
|
|
83
86
|
```
|
|
84
87
|
|
|
85
|
-
The result is `"Todos for family"`. `ctx.get`
|
|
86
|
-
|
|
88
|
+
The result is `"Todos for family"`. `await ctx.get` resolves once the object is
|
|
89
|
+
live (and, as we will see, has its state). The reference acquired through
|
|
87
90
|
`ctx.get` is released when this root ends. We never receive the factory's
|
|
88
91
|
returned object directly. A standalone call without `ctx`, such as
|
|
89
|
-
`todos.label(
|
|
92
|
+
`todos.label()`, starts its own root. A method without arguments takes only
|
|
93
|
+
the context: `todos.label(ctx)`; its implementation is `(ctx) => …`. Inside a root or method, pass the
|
|
90
94
|
existing `ctx` so nested calls stay in the same call tree.
|
|
91
95
|
|
|
92
96
|
The factory may also receive `principal` (who is asking) and `self` (the
|
|
93
97
|
object's declaration API). We did not need them yet.
|
|
94
98
|
|
|
95
|
-
> **Why:** The
|
|
99
|
+
> **Why:** The reference separates callers from implementations. Later, replacing
|
|
96
100
|
> code will not require replacing every caller's reference.
|
|
97
101
|
|
|
98
102
|
## 3. Give the list live state
|
|
@@ -105,8 +109,10 @@ Extend the contract with `rows`, `openCount`, and editing methods. State
|
|
|
105
109
|
members describe values, not methods. This adds members at contract version 1;
|
|
106
110
|
older callers that only need `label` still have everything they need.
|
|
107
111
|
|
|
108
|
-
`self.state` declares
|
|
109
|
-
Chord matches that name to the existing cell and
|
|
112
|
+
`self.state(name, schema, initial)` declares a cell and returns it. The name
|
|
113
|
+
`rows` is important: on reload, Chord matches that name to the existing cell and
|
|
114
|
+
keeps its value. `self.derive(sources, fn)` declares a derivation; it has no
|
|
115
|
+
name, because it holds nothing a reload must keep.
|
|
110
116
|
|
|
111
117
|
<!-- example:03-contract -->
|
|
112
118
|
```ts
|
|
@@ -116,51 +122,52 @@ const TodoStore = local("tutorial.todos", {
|
|
|
116
122
|
version: 1,
|
|
117
123
|
input: Type.Object({ list: Type.String() }),
|
|
118
124
|
members: {
|
|
119
|
-
label: method(
|
|
125
|
+
label: method(undefined, Type.String()),
|
|
120
126
|
rows: Rows,
|
|
121
127
|
openCount: Type.Number(),
|
|
122
|
-
add: method(Type.Object({ text: Type.String() })
|
|
123
|
-
finish: method(Type.Object({ index: Type.Integer() })
|
|
124
|
-
reset: method(Rows
|
|
128
|
+
add: method(Type.Object({ text: Type.String() })),
|
|
129
|
+
finish: method(Type.Object({ index: Type.Integer() })),
|
|
130
|
+
reset: method(Rows),
|
|
125
131
|
},
|
|
126
132
|
});
|
|
127
133
|
```
|
|
128
134
|
|
|
129
|
-
The factory now declares a cell and a derivation.
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
The factory now declares a cell and a derivation. It is wrapped in `todoStore`,
|
|
136
|
+
which takes the cell's options (section 9 raises its version); for now
|
|
137
|
+
`todoFactory` uses the defaults. Each method receives its arguments and a
|
|
138
|
+
context; changing the cell updates its readers and its derived count together.
|
|
139
|
+
`add`, `finish` and `reset` declare no result, so they return nothing.
|
|
132
140
|
|
|
133
141
|
<!-- example:03-factory -->
|
|
134
142
|
```ts
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
},
|
|
143
|
+
/** The store's factory; `options` versions its rows (section 9). */
|
|
144
|
+
const todoStore =
|
|
145
|
+
(options?: StateOptions<Static<typeof Rows>>): Factory<typeof TodoStore> =>
|
|
146
|
+
({ list }, _principal, self) => {
|
|
147
|
+
const rows = self.state("rows", Rows, [], options);
|
|
148
|
+
const openCount = self.derive([rows], (todos) => todos.filter((todo) => !todo.done).length);
|
|
149
|
+
return {
|
|
150
|
+
label: () => `Todos for ${list}`,
|
|
151
|
+
rows,
|
|
152
|
+
openCount,
|
|
153
|
+
add: ({ text }, ctx) => {
|
|
154
|
+
rows.change(ctx, (draft) => {
|
|
155
|
+
draft.push({ text, done: false });
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
finish: ({ index }, ctx) => {
|
|
159
|
+
rows.change(ctx, (draft) => {
|
|
160
|
+
const todo = draft[index];
|
|
161
|
+
if (!todo) throw invalid("args", "No such todo");
|
|
162
|
+
todo.done = true;
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
reset: (next, ctx) => {
|
|
166
|
+
rows.replace(ctx, next, { diff: true });
|
|
167
|
+
},
|
|
168
|
+
};
|
|
162
169
|
};
|
|
163
|
-
|
|
170
|
+
const todoFactory = todoStore();
|
|
164
171
|
```
|
|
165
172
|
|
|
166
173
|
`change(ctx, fn)` gives `fn` a temporary mutable **draft**. Here `add`
|
|
@@ -176,10 +183,9 @@ receive the same resulting value.
|
|
|
176
183
|
|
|
177
184
|
<!-- example:03-call -->
|
|
178
185
|
```ts
|
|
179
|
-
const host = createHost({ dev: true });
|
|
186
|
+
const host = createHost({ self: "server", dev: true });
|
|
180
187
|
host.serve(TodoStore, todoFactory);
|
|
181
|
-
const todos = host.get(TodoStore, { list: "family" });
|
|
182
|
-
await todos.ready;
|
|
188
|
+
const todos = await host.get(TodoStore, { list: "family" });
|
|
183
189
|
const counts: (number | undefined)[] = [];
|
|
184
190
|
const stop = todos.openCount.subscribe((value) => counts.push(value));
|
|
185
191
|
await host.root(async (ctx) => {
|
|
@@ -197,19 +203,19 @@ The count subscriber sees `0, 1, 0`: initially empty, one unfinished todo,
|
|
|
197
203
|
then no unfinished todos. Resetting to empty does not add another count
|
|
198
204
|
notification because the derived count is still zero. The final rows are `[]`.
|
|
199
205
|
`subscribe` calls back immediately with the current value and returns an
|
|
200
|
-
unsubscribe function.
|
|
201
|
-
|
|
206
|
+
unsubscribe function. The awaited `get` makes sure there is a value: a reference
|
|
207
|
+
you hold is live, with its state.
|
|
202
208
|
|
|
203
209
|
> **Why:** Store reloadable state in cells, not closure variables. Treat
|
|
204
|
-
> `cell.value`, `
|
|
210
|
+
> `cell.value`, `ref.rows.value`, and subscriber values as immutable.
|
|
205
211
|
> Only mutate drafts inside `change`; do not keep a draft for later use.
|
|
206
212
|
> A derivation runs synchronously, so use it for projections, not database work.
|
|
207
213
|
|
|
208
214
|
## 4. Let objects use objects and resources
|
|
209
215
|
|
|
210
|
-
Saving a todo may require both the list and a database connection. A
|
|
211
|
-
|
|
212
|
-
owned by this object; it lasts longer than a single method call.
|
|
216
|
+
Saving a todo may require both the list and a database connection. A
|
|
217
|
+
**reference** keeps the object it refers to alive. `self.get` declares a
|
|
218
|
+
reference owned by this object; it lasts longer than a single method call.
|
|
213
219
|
|
|
214
220
|
A **resource** is something the runtime opens and later closes, such as a
|
|
215
221
|
database connection. Its `open` may be asynchronous even though the factory
|
|
@@ -230,23 +236,21 @@ const Database = local("tutorial.database", {
|
|
|
230
236
|
const SavedTodos = local("tutorial.saved-todos", {
|
|
231
237
|
version: 1,
|
|
232
238
|
input: Type.Object({ list: Type.String() }),
|
|
233
|
-
members: { add: method(Type.Object({ text: Type.String() })
|
|
239
|
+
members: { add: method(Type.Object({ text: Type.String() })) },
|
|
234
240
|
});
|
|
235
|
-
const host = createHost();
|
|
241
|
+
const host = createHost({ self: "server" });
|
|
236
242
|
let opens = 0;
|
|
237
243
|
const closed = Promise.withResolvers<void>();
|
|
238
244
|
host.serve(Database, (_input, _principal, self) => {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
close: () => closed.resolve(),
|
|
247
|
-
}),
|
|
245
|
+
const db = self.resource("db", {
|
|
246
|
+
version: 1,
|
|
247
|
+
open: async () => {
|
|
248
|
+
opens++;
|
|
249
|
+
return { saved: [] as string[] };
|
|
250
|
+
},
|
|
251
|
+
close: () => closed.resolve(),
|
|
248
252
|
});
|
|
249
|
-
return { db: self.expose(
|
|
253
|
+
return { db: self.expose(db) };
|
|
250
254
|
});
|
|
251
255
|
```
|
|
252
256
|
|
|
@@ -269,7 +273,6 @@ host.serve(
|
|
|
269
273
|
add: async ({ text }, ctx) => {
|
|
270
274
|
database.db!.saved.push(text);
|
|
271
275
|
await todos.add({ text }, ctx);
|
|
272
|
-
return null;
|
|
273
276
|
},
|
|
274
277
|
};
|
|
275
278
|
},
|
|
@@ -277,14 +280,13 @@ host.serve(
|
|
|
277
280
|
);
|
|
278
281
|
```
|
|
279
282
|
|
|
280
|
-
An outside consumer holds a `SavedTodos`
|
|
283
|
+
An outside consumer holds a `SavedTodos` reference. We also hold `Database`
|
|
281
284
|
directly so we can inspect what was saved.
|
|
282
285
|
|
|
283
286
|
<!-- example:04-call -->
|
|
284
287
|
```ts
|
|
285
|
-
const saved = host.get(SavedTodos, { list: "family" });
|
|
286
|
-
const database = host.get(Database, {});
|
|
287
|
-
await saved.ready;
|
|
288
|
+
const saved = await host.get(SavedTodos, { list: "family" });
|
|
289
|
+
const database = await host.get(Database, {});
|
|
288
290
|
await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx));
|
|
289
291
|
assert.deepEqual(database.db!.saved, ["Buy milk"]);
|
|
290
292
|
assert.equal(opens, 1);
|
|
@@ -298,25 +300,25 @@ assert.ok(statuses.includes("released"));
|
|
|
298
300
|
```
|
|
299
301
|
|
|
300
302
|
After the method, the fake database contains `["Buy milk"]`. It opened once,
|
|
301
|
-
even though two objects
|
|
303
|
+
even though two objects referenced it. `SavedTodos` and `Database` become live
|
|
302
304
|
only after their resources and declared dependencies are ready. That is why
|
|
303
305
|
`database.db!` is safe inside this live object's method: activation has
|
|
304
|
-
already established that dependency. An outside caller
|
|
306
|
+
already established that dependency. An outside caller awaits its `get`.
|
|
305
307
|
|
|
306
|
-
When both external
|
|
307
|
-
releases its own database
|
|
308
|
-
wait for `closed.promise` to prove it. The released
|
|
308
|
+
When both external references are released, `SavedTodos` is disposed and
|
|
309
|
+
releases its own database reference. With no references left, the database closes; we
|
|
310
|
+
wait for `closed.promise` to prove it. The released reference stays
|
|
309
311
|
`released`, and `onStatus` reports that transition. `onStatus` watches
|
|
310
312
|
changes; unlike state subscription, it does not deliver an initial status.
|
|
311
313
|
|
|
312
|
-
`linger` is a delay in milliseconds after the last
|
|
314
|
+
`linger` is a delay in milliseconds after the last reference is released. Zero disposes
|
|
313
315
|
without a grace period; a positive value can avoid reopening an expensive
|
|
314
316
|
resource when a screen is briefly hidden. Linger is not persistence.
|
|
315
317
|
|
|
316
|
-
> **Tip:** A factory only declares dependencies. Never call a
|
|
318
|
+
> **Tip:** A factory only declares dependencies. Never call a referenced object's method or
|
|
317
319
|
> read `resource.value` there, including during reload. Activation happens
|
|
318
|
-
> afterward. Bump a resource's `
|
|
319
|
-
> Disposal stops tasks and listeners, closes resources, then releases
|
|
320
|
+
> afterward. Bump a resource's `version` when its configuration should be reopened.
|
|
321
|
+
> Disposal stops tasks and listeners, closes resources, then releases references.
|
|
320
322
|
|
|
321
323
|
## 5. Who is asking? One store, two views
|
|
322
324
|
|
|
@@ -327,32 +329,38 @@ whether an editing method is available.
|
|
|
327
329
|
|
|
328
330
|
Do not create a separate data cell for each viewer: their lists would diverge.
|
|
329
331
|
Instead, each view holds the same host-local `TodoStore` and exposes its rows.
|
|
330
|
-
`self.get`
|
|
331
|
-
that store. Remote view
|
|
332
|
+
`self.get` acts as the host's own principal, so owner and guest views share
|
|
333
|
+
that store. Remote view objects themselves are separate per principal key.
|
|
334
|
+
A remote contract also says where its objects live: `host: hostNamed("server")`
|
|
335
|
+
puts every `Todos` object on the host named `server`. Section 6 uses that.
|
|
332
336
|
|
|
333
337
|
<!-- example:05-views -->
|
|
334
338
|
```ts
|
|
335
339
|
const Todos = remote("tutorial.todo-view", {
|
|
336
340
|
version: 1,
|
|
337
341
|
input: Type.Object({ list: Type.String() }),
|
|
342
|
+
host: hostNamed("server"),
|
|
338
343
|
variants: {
|
|
339
344
|
viewer: { state: { rows: Rows } },
|
|
340
345
|
editor: {
|
|
341
346
|
state: { rows: Rows },
|
|
342
|
-
methods: { add: method(Type.Object({ text: Type.String() })
|
|
347
|
+
methods: { add: method(Type.Object({ text: Type.String() })) },
|
|
343
348
|
},
|
|
344
349
|
},
|
|
345
350
|
});
|
|
346
351
|
|
|
347
|
-
|
|
352
|
+
/** Who may ask: the owner, a guest, or the server's own code. */
|
|
353
|
+
type Principal = "owner" | "guest" | "server";
|
|
354
|
+
|
|
355
|
+
function serveViews(api: Pick<Host<Principal>, "serve">): void {
|
|
348
356
|
api.serve(Todos, ({ list }, principal, self) => {
|
|
349
357
|
if (list !== "family") throw forbidden("This list is not shared");
|
|
350
358
|
const store = self.get(TodoStore, { list });
|
|
351
|
-
if (principal
|
|
352
|
-
return {
|
|
359
|
+
if (principal === "guest") {
|
|
360
|
+
return { variant: "viewer", rows: store.rows };
|
|
353
361
|
}
|
|
354
362
|
return {
|
|
355
|
-
|
|
363
|
+
variant: "editor",
|
|
356
364
|
rows: store.rows,
|
|
357
365
|
add: ({ text }, ctx) => {
|
|
358
366
|
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
|
|
@@ -364,29 +372,35 @@ function serveViews(api: Pick<Host, "serve">): void {
|
|
|
364
372
|
```
|
|
365
373
|
|
|
366
374
|
Here principals are the strings `"owner"` and `"guest"`, assigned by
|
|
367
|
-
trusted application code
|
|
368
|
-
`
|
|
369
|
-
|
|
375
|
+
trusted application code, and `"server"`, the host's own code: every host is
|
|
376
|
+
created with `self`, the principal its own roots and objects act as. Chord
|
|
377
|
+
invents no principals. The `Principal` type parameter of `createHost` and
|
|
378
|
+
`Host` makes `principal` in the factory that type, so TypeScript checks the
|
|
379
|
+
comparisons. `principalKey` maps identity to object identity. Real
|
|
380
|
+
applications should use stable identity, connection provenance, and grant
|
|
381
|
+
revision, not display names.
|
|
370
382
|
|
|
371
383
|
<!-- example:05-call -->
|
|
372
384
|
```ts
|
|
373
|
-
const host = createHost({ principalKey: (principal) =>
|
|
385
|
+
const host = createHost<Principal>({ name: "server", self: "server", principalKey: (principal) => principal });
|
|
374
386
|
host.serve(TodoStore, todoFactory);
|
|
375
387
|
serveViews(host);
|
|
376
|
-
const owner = host.get(Todos, { list: "family" }, { principal: "owner" });
|
|
377
|
-
const guest = host.get(Todos, { list: "family" }, { principal: "guest" });
|
|
378
|
-
|
|
379
|
-
assert.equal(
|
|
380
|
-
assert.equal(guest.kind, "viewer");
|
|
388
|
+
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
|
389
|
+
const guest = await host.get(Todos, { list: "family" }, { principal: "guest" });
|
|
390
|
+
assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant
|
|
391
|
+
assert.equal(guest.variant, "viewer");
|
|
381
392
|
await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" });
|
|
382
393
|
assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]);
|
|
394
|
+
// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider.
|
|
383
395
|
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
|
|
384
396
|
await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid"));
|
|
385
397
|
```
|
|
386
398
|
|
|
387
399
|
The owner gets `editor`; the guest gets `viewer`. Both see the same milk
|
|
388
|
-
todo.
|
|
389
|
-
|
|
400
|
+
todo. A reference is typed as the union of its variants: `add` is reachable only
|
|
401
|
+
after narrowing on `variant` (`assert.ok(owner.variant === "editor")` does that).
|
|
402
|
+
The guest's `add` is refused by TypeScript, and, called anyway, rejects with
|
|
403
|
+
`not_in_variant`. Whitespace-only text
|
|
390
404
|
rejects with `invalid`: it is a string, as the schema demands, but it breaks a
|
|
391
405
|
rule only the method knows. Use `invalid` for bad input and `forbidden` only
|
|
392
406
|
for callers who may not do something.
|
|
@@ -394,58 +408,67 @@ for callers who may not do something.
|
|
|
394
408
|
> **Why:** A variant is not all of authorization. The factory checks which
|
|
395
409
|
> list may be opened; the method checks the operation's arguments. Production
|
|
396
410
|
> code must also enforce grants that can change. Use
|
|
397
|
-
> `host.invalidatePrincipal(match)` to revoke matching
|
|
398
|
-
> become `gone`. A UI watches `onStatus` and gets a new
|
|
411
|
+
> `host.invalidatePrincipal(match)` to revoke matching objects; held references
|
|
412
|
+
> become `gone`. A UI watches `onStatus` and gets a new reference when appropriate.
|
|
399
413
|
> Guest-provided names or message text must never establish identity.
|
|
400
414
|
|
|
401
415
|
## 6. Reach the list from another process
|
|
402
416
|
|
|
403
417
|
A browser cannot call a host-only contract. `remote` contracts describe only
|
|
404
418
|
schema-checked state and methods that can travel as JSON. Our `Todos` view is
|
|
405
|
-
remote; the underlying `TodoStore` and database stay local.
|
|
419
|
+
remote; the underlying `TodoStore` and database stay local. A caller never
|
|
420
|
+
names a host: `browser.get(Todos, …)` goes to `server` because the contract
|
|
421
|
+
says so, and the server refuses to serve, or to be asked for, a type it is not
|
|
422
|
+
the place of. A type that lives on one host per session says
|
|
423
|
+
`host: hostFamily("session:", "session")`: its input's `session` field picks
|
|
424
|
+
the host (`{ session: "S1" }` lives on `session:S1`).
|
|
406
425
|
|
|
407
426
|
A **link** carries ordered messages between hosts. A memory link runs both ends
|
|
408
427
|
in one test process, but uses the remote protocol. The accepting host stamps
|
|
409
428
|
the caller's principal from its authenticator, not from method arguments or a
|
|
410
|
-
client's claimed identity.
|
|
429
|
+
client's claimed identity. The connecting side names the principal its parent's
|
|
430
|
+
own requests act as (`upstream: { link, principal }`, or `connect(link, {
|
|
431
|
+
principal })`).
|
|
411
432
|
|
|
412
433
|
<!-- example:06 -->
|
|
413
434
|
```ts
|
|
414
|
-
const server = createHost({ name: "server" });
|
|
435
|
+
const server = createHost<Principal>({ self: "server", name: "server" });
|
|
415
436
|
server.serve(TodoStore, todoFactory);
|
|
416
437
|
serveViews(server);
|
|
417
438
|
const [down, up] = memoryLinkPair();
|
|
418
439
|
server.accept(down, { principal: "owner", kind: "client" });
|
|
419
|
-
const browser = createHost({
|
|
420
|
-
|
|
421
|
-
|
|
440
|
+
const browser = createHost({
|
|
441
|
+
self: "browser",
|
|
442
|
+
name: "browser",
|
|
443
|
+
kind: "client",
|
|
444
|
+
upstream: { link: up, principal: "server" },
|
|
445
|
+
});
|
|
446
|
+
const todos = await browser.get(Todos, { list: "family" });
|
|
447
|
+
assert.ok(todos.variant === "editor");
|
|
448
|
+
assert.deepEqual(todos.rows.value, []);
|
|
422
449
|
const updated = Promise.withResolvers<void>();
|
|
423
450
|
const stop = todos.rows.subscribe((rows) => {
|
|
424
|
-
if (rows
|
|
425
|
-
if (rows?.length === 1) updated.resolve();
|
|
451
|
+
if (rows.length === 1) updated.resolve();
|
|
426
452
|
});
|
|
427
|
-
await todos.ready;
|
|
428
|
-
await hydrated.promise;
|
|
429
|
-
assert.deepEqual(todos.rows.value, []);
|
|
430
453
|
await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));
|
|
431
454
|
await updated.promise;
|
|
432
455
|
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
|
433
456
|
// Deliberately bypass TypeScript to simulate a malformed caller.
|
|
434
457
|
await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid"));
|
|
435
458
|
stop();
|
|
436
|
-
|
|
459
|
+
// A held reference keeps its state without listeners.
|
|
460
|
+
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
|
437
461
|
todos.release();
|
|
438
462
|
```
|
|
439
463
|
|
|
440
464
|
The browser sees an empty snapshot, then the new milk todo. `ready` means
|
|
441
|
-
the remote
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
`undefined` when its last subscriber leaves.
|
|
465
|
+
the remote reference has bound to a live object **and** has its state: every
|
|
466
|
+
state member of its variant has a snapshot. A held reference stays subscribed,
|
|
467
|
+
with or without listeners, until it is released.
|
|
445
468
|
|
|
446
469
|
What crosses the wire: the input and consumer's contract description, the
|
|
447
|
-
provider's shape (variant and members), method arguments and results, and
|
|
448
|
-
|
|
470
|
+
provider's shape (variant and members), method arguments and results, and the
|
|
471
|
+
state of the reference's variant. State starts with a **snapshot**, the whole current value;
|
|
449
472
|
subsequent **deltas** describe changes. Rebinds and reloads can send a
|
|
450
473
|
`reset`, a fresh whole value. Functions, resource connections, and cell
|
|
451
474
|
instances do not cross the wire.
|
|
@@ -479,35 +502,30 @@ const Activity = local("tutorial.activity", {
|
|
|
479
502
|
input: Type.Object({}),
|
|
480
503
|
members: { seen: Type.Number() },
|
|
481
504
|
});
|
|
482
|
-
const host = createHost();
|
|
505
|
+
const host = createHost({ self: "server" });
|
|
483
506
|
host.serve(TodoStore, todoFactory);
|
|
484
507
|
const observed = Promise.withResolvers<void>();
|
|
485
508
|
const seeded = Promise.withResolvers<void>();
|
|
486
509
|
const stopped = Promise.withResolvers<void>();
|
|
487
510
|
host.serve(Activity, (_input, _principal, self) => {
|
|
488
511
|
const todos = self.get(TodoStore, { list: "family" });
|
|
489
|
-
const
|
|
490
|
-
self.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (rows?.length === 1) observed.resolve();
|
|
494
|
-
}),
|
|
512
|
+
const seen = self.state("seen", Type.Number(), 0);
|
|
513
|
+
self.on("rows", todos.rows, (rows, ctx) => {
|
|
514
|
+
seen.replace(ctx, rows?.length ?? 0);
|
|
515
|
+
if (rows?.length === 1) observed.resolve();
|
|
495
516
|
});
|
|
496
|
-
self.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
stopped.resolve();
|
|
505
|
-
},
|
|
517
|
+
self.task(async (ctx) => {
|
|
518
|
+
await todos.add({ text: "Review the list" }, ctx);
|
|
519
|
+
seeded.resolve();
|
|
520
|
+
await new Promise<void>((resolve) => {
|
|
521
|
+
if (ctx.signal.aborted) resolve();
|
|
522
|
+
else ctx.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
523
|
+
});
|
|
524
|
+
stopped.resolve();
|
|
506
525
|
});
|
|
507
|
-
return { seen
|
|
526
|
+
return { seen };
|
|
508
527
|
});
|
|
509
|
-
const activity = host.get(Activity, {});
|
|
510
|
-
await activity.ready;
|
|
528
|
+
const activity = await host.get(Activity, {});
|
|
511
529
|
await Promise.all([seeded.promise, observed.promise]);
|
|
512
530
|
assert.equal(activity.seen.value, 1);
|
|
513
531
|
activity.release();
|
|
@@ -518,13 +536,19 @@ The final `seen` is `1`. Each listener event receives a fresh root's
|
|
|
518
536
|
`ctx`; its write belongs to that short call tree. A task is not one enormous
|
|
519
537
|
root: Chord method calls made with its task context start fresh short roots.
|
|
520
538
|
We pass that context into `todos.add` so cancellation follows the task.
|
|
521
|
-
Releasing the activity
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
539
|
+
Releasing the activity reference aborts its task and resolves `stopped`.
|
|
540
|
+
|
|
541
|
+
`self.on(name, source, handler)` declares a listener; `self.task(fn)` a task.
|
|
542
|
+
A listener on state, like `rows` here, gets the current value when it starts,
|
|
543
|
+
then every change. It gets the current value again whenever a reload replaced
|
|
544
|
+
its source (a derivation is new at every reload), so its handler must
|
|
545
|
+
reconcile (`seen` is set from the rows, not counted up). Listeners can also
|
|
546
|
+
subscribe to a resource's event stream with `self.on(name, resource, select,
|
|
547
|
+
handler)`: `select` gets the opened resource and returns something with
|
|
548
|
+
`subscribe`; an event stream delivers no current value. Kept subscriptions
|
|
549
|
+
survive reload while the resource and selector remain unchanged; the handler
|
|
550
|
+
switches to new code. A selector that captures changing configuration should
|
|
551
|
+
get a new listener name. Tasks have no names: a reload restarts them all.
|
|
528
552
|
|
|
529
553
|
> **Why:** Do not wait for a person, sleep for minutes, or run an endless loop
|
|
530
554
|
> inside a method. That pins a root and can block state migration. Store a
|
|
@@ -534,13 +558,14 @@ A selector that captures changing configuration should get a new listener name.
|
|
|
534
558
|
## 8. Package features as plugins
|
|
535
559
|
|
|
536
560
|
Now a plugin can contribute a menu action without editing a central switch.
|
|
537
|
-
A **registry** is a named table of contributions. A **facet**
|
|
538
|
-
|
|
561
|
+
A **registry** is a named table of contributions. A **facet** is a plugin's code
|
|
562
|
+
for one kind of host: it serves object types. A plugin can have server, worker, and browser
|
|
539
563
|
facets, each installing that part of the feature.
|
|
540
564
|
|
|
541
|
-
Registrations belong to
|
|
542
|
-
|
|
543
|
-
|
|
565
|
+
Registrations belong to objects of **lifetime `"host"`**: the host creates such
|
|
566
|
+
an object when its facet loads and keeps it until the host closes, even if no
|
|
567
|
+
screen asks for it. That keeps our menu contribution present. Objects of the
|
|
568
|
+
default lifetime, `"referenced"`, are still created only when referenced.
|
|
544
569
|
|
|
545
570
|
<!-- example:08 -->
|
|
546
571
|
```ts
|
|
@@ -562,20 +587,20 @@ const menuFacet: Facet = {
|
|
|
562
587
|
self.register(Actions, {
|
|
563
588
|
addSample: async (ctx) => {
|
|
564
589
|
await todos.add({ text: "Try plugins" }, ctx);
|
|
565
|
-
return todos.openCount.value
|
|
590
|
+
return todos.openCount.value;
|
|
566
591
|
},
|
|
567
592
|
});
|
|
568
593
|
return {};
|
|
569
594
|
},
|
|
570
|
-
{
|
|
595
|
+
{ lifetime: "host" },
|
|
571
596
|
);
|
|
572
597
|
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
|
|
573
598
|
},
|
|
574
599
|
{ uses: [TodoStore] },
|
|
575
600
|
),
|
|
576
601
|
};
|
|
577
|
-
const host = createHost();
|
|
578
|
-
await host.load([menuFacet, storeFacet], {
|
|
602
|
+
const host = createHost({ self: "server" });
|
|
603
|
+
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
|
|
579
604
|
await host.ready();
|
|
580
605
|
assert.equal(await click!(), 1);
|
|
581
606
|
assert.equal(host.registry(Actions).table().size, 1);
|
|
@@ -583,7 +608,7 @@ assert.equal(host.registry(Actions).table().size, 1);
|
|
|
583
608
|
|
|
584
609
|
Clicking returns `1`, and the registry has one entry. The menu's dependency
|
|
585
610
|
can come from a later facet in the load batch: `load` installs the whole
|
|
586
|
-
batch before waiting for
|
|
611
|
+
batch before waiting for host objects to activate. The facet declares `uses` so
|
|
587
612
|
reload can also check the contract it consumes.
|
|
588
613
|
|
|
589
614
|
The facet API supplies `serve`, `name`, `root`, and `registry`.
|
|
@@ -593,16 +618,16 @@ root's code version. Without `ctx`, lookup uses the current table. Duplicate
|
|
|
593
618
|
keys fail by default; `subscribe` can bridge tables to an imperative consumer.
|
|
594
619
|
|
|
595
620
|
Normally use `await host.load(facets)` (or `await host.install(facet)`),
|
|
596
|
-
which waits for those
|
|
597
|
-
with `
|
|
598
|
-
snapshot of the host's
|
|
621
|
+
which waits for those host objects. This example explicitly defers that barrier
|
|
622
|
+
with `awaitHostObjects: false`, then awaits `host.ready()`. That waits for a
|
|
623
|
+
snapshot of the host's objects, not for task completion or later loads.
|
|
599
624
|
Failures reject the readiness promise; abandoning startup requires closing the
|
|
600
625
|
host, because installation is not a rollback transaction.
|
|
601
626
|
|
|
602
627
|
> **Tip:** Use deferred readiness only when needed. A worker may have to await
|
|
603
|
-
> an independent local bootstrap
|
|
628
|
+
> an independent local bootstrap reference, announce control readiness so its
|
|
604
629
|
> parent admits its link, and only then await `host.ready()`. Waiting for
|
|
605
|
-
> upstream-dependent
|
|
630
|
+
> upstream-dependent host objects before admission would deadlock.
|
|
606
631
|
|
|
607
632
|
For real plugin files, default-export `defineFacet(...)`. Node hosts configure
|
|
608
633
|
`createHost({ loader: nodeLoader })`, with `nodeLoader` imported from
|
|
@@ -613,19 +638,19 @@ copies in memory, so repeated loading has that limitation.
|
|
|
613
638
|
|
|
614
639
|
## 9. Change running code without losing the list
|
|
615
640
|
|
|
616
|
-
A
|
|
617
|
-
change. An **
|
|
618
|
-
A root keeps the
|
|
641
|
+
A reference is useful precisely because callers keep it while implementations
|
|
642
|
+
change. An **code version** identifies one committed set of code and registry tables.
|
|
643
|
+
A root keeps the code version it started with. New roots use the latest one.
|
|
619
644
|
|
|
620
645
|
### Compatible code change: no waiting
|
|
621
646
|
|
|
622
647
|
Give the same named facet a new factory. It changes the label but keeps the
|
|
623
|
-
cell
|
|
648
|
+
cell declaration. This test deliberately pauses one caller to show what an
|
|
624
649
|
old root sees; application methods should not pause like this.
|
|
625
650
|
|
|
626
651
|
<!-- example:09-compatible -->
|
|
627
652
|
```ts
|
|
628
|
-
const host = createHost();
|
|
653
|
+
const host = createHost({ self: "server" });
|
|
629
654
|
const facet = (label: string): Facet => ({
|
|
630
655
|
name: "todos",
|
|
631
656
|
setup: (api) =>
|
|
@@ -635,20 +660,20 @@ const facet = (label: string): Facet => ({
|
|
|
635
660
|
})),
|
|
636
661
|
});
|
|
637
662
|
await host.load([facet("Original")]);
|
|
638
|
-
const todos = host.get(TodoStore, { list: "family" });
|
|
663
|
+
const todos = await host.get(TodoStore, { list: "family" });
|
|
639
664
|
await todos.add({ text: "Buy milk" });
|
|
640
665
|
const entered = Promise.withResolvers<void>();
|
|
641
666
|
const resume = Promise.withResolvers<void>();
|
|
642
667
|
const oldRoot = host.root(async (ctx) => {
|
|
643
|
-
const first = await todos.label(
|
|
668
|
+
const first = await todos.label(ctx);
|
|
644
669
|
entered.resolve();
|
|
645
670
|
await resume.promise; // Test-only pause: pin an old caller during reload.
|
|
646
|
-
return [first, await todos.label(
|
|
671
|
+
return [first, await todos.label(ctx)];
|
|
647
672
|
});
|
|
648
673
|
await entered.promise;
|
|
649
674
|
const compatible = await host.reload([facet("Updated")]);
|
|
650
675
|
assert.deepEqual(compatible.gated, []);
|
|
651
|
-
assert.equal(await todos.label(
|
|
676
|
+
assert.equal(await todos.label(), "Updated");
|
|
652
677
|
resume.resolve();
|
|
653
678
|
assert.deepEqual(await oldRoot, ["Original", "Original"]);
|
|
654
679
|
assert.equal(todos.rows.value?.[0]?.text, "Buy milk");
|
|
@@ -661,9 +686,10 @@ survives. Compatible code changes do not wait for that old root to finish.
|
|
|
661
686
|
### State change: bump its version and migrate
|
|
662
687
|
|
|
663
688
|
Suppose a new policy converts existing todo text to uppercase. The schema is
|
|
664
|
-
unchanged, but the data's meaning changes. We still bump the **state
|
|
665
|
-
|
|
666
|
-
A **migration** converts an
|
|
689
|
+
unchanged, but the data's meaning changes. We still bump the cell's **state
|
|
690
|
+
version** (`self.state`'s `version` option, default 1). It belongs to one cell,
|
|
691
|
+
independently of the contract version. A **migration** (`migrate`) converts an
|
|
692
|
+
old value of that cell to the new representation.
|
|
667
693
|
|
|
668
694
|
<!-- example:09-migrate -->
|
|
669
695
|
```ts
|
|
@@ -673,17 +699,16 @@ const migrated = await host.reload([
|
|
|
673
699
|
{
|
|
674
700
|
name: "todos",
|
|
675
701
|
setup: (api) =>
|
|
676
|
-
api.serve(
|
|
677
|
-
|
|
702
|
+
api.serve(
|
|
703
|
+
TodoStore,
|
|
704
|
+
todoStore({
|
|
678
705
|
version: 2,
|
|
679
|
-
migrate: {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
|
|
683
|
-
},
|
|
706
|
+
migrate: (old, from) => {
|
|
707
|
+
assert.equal(from, 1);
|
|
708
|
+
return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
|
|
684
709
|
},
|
|
685
|
-
},
|
|
686
|
-
|
|
710
|
+
}),
|
|
711
|
+
),
|
|
687
712
|
},
|
|
688
713
|
]);
|
|
689
714
|
assert.equal(migrated.gated.length, 1);
|
|
@@ -693,24 +718,24 @@ stopEvents();
|
|
|
693
718
|
```
|
|
694
719
|
|
|
695
720
|
The migration receives the old rows and their state version, `1`. It returns
|
|
696
|
-
new rows; the
|
|
721
|
+
new rows; the reference now reads `[{ text: "BUY MILK", done: false }]`.
|
|
697
722
|
Subscribers get a `reset` event containing the migrated value. The event
|
|
698
723
|
assertion sees an initial `snap`, then that `reset`.
|
|
699
|
-
One
|
|
724
|
+
One object is gated. A **gate** waits for calls already inside that object to
|
|
700
725
|
finish before migrating a private copy, validating it, and committing. Old
|
|
701
726
|
roots that had not entered it cannot later use old code over migrated data:
|
|
702
727
|
they get `upgrade_conflict` and should retry in a new root.
|
|
703
728
|
|
|
704
|
-
Changing a cell's schema also requires a
|
|
705
|
-
|
|
729
|
+
Changing a cell's schema also requires a version bump, and a migration unless
|
|
730
|
+
the old value already fits the new schema. A change of meaning with the same schema,
|
|
706
731
|
like this example, cannot be detected automatically. Real migrations must
|
|
707
|
-
|
|
732
|
+
reference each stored version they support, not assume every installation is
|
|
708
733
|
exactly one version behind.
|
|
709
734
|
|
|
710
735
|
### Contract change: keep an adapter for old callers
|
|
711
736
|
|
|
712
737
|
Rename `label` to `heading`. That is a breaking contract change, so use
|
|
713
|
-
contract version 2. The provider's `accepts[1]` adapter builds the
|
|
738
|
+
contract version 2. The provider's `accepts[1]` adapter builds the implementation
|
|
714
739
|
that version-1 consumers still expect.
|
|
715
740
|
|
|
716
741
|
<!-- example:09-contract -->
|
|
@@ -720,7 +745,7 @@ const { label: _label, ...members } = TodoStore.def.members;
|
|
|
720
745
|
const RenamedStore = local("tutorial.todos", {
|
|
721
746
|
version: 2,
|
|
722
747
|
input: TodoStore.def.input,
|
|
723
|
-
members: { ...members, heading: method(
|
|
748
|
+
members: { ...members, heading: method(undefined, Type.String()) },
|
|
724
749
|
});
|
|
725
750
|
await host.reload([
|
|
726
751
|
{
|
|
@@ -729,11 +754,10 @@ await host.reload([
|
|
|
729
754
|
api.serve(
|
|
730
755
|
RenamedStore,
|
|
731
756
|
(input, principal, self) => {
|
|
732
|
-
const { label, ...
|
|
733
|
-
return { ...
|
|
757
|
+
const { label, ...implementation } = todoStore({ version: 2 })(input, principal, self);
|
|
758
|
+
return { ...implementation, heading: label };
|
|
734
759
|
},
|
|
735
760
|
{
|
|
736
|
-
state: { version: 2 },
|
|
737
761
|
accepts: {
|
|
738
762
|
1: (current) => ({ ...current, label: current.heading }),
|
|
739
763
|
},
|
|
@@ -741,19 +765,20 @@ await host.reload([
|
|
|
741
765
|
),
|
|
742
766
|
},
|
|
743
767
|
]);
|
|
744
|
-
assert.equal(await todos.label(
|
|
745
|
-
const newer = host.get(RenamedStore, { list: "family" });
|
|
746
|
-
assert.equal(await newer.heading(
|
|
768
|
+
assert.equal(await todos.label(), "Todos for family");
|
|
769
|
+
const newer = await host.get(RenamedStore, { list: "family" });
|
|
770
|
+
assert.equal(await newer.heading(), "Todos for family");
|
|
747
771
|
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");
|
|
748
772
|
```
|
|
749
773
|
|
|
750
|
-
The old
|
|
774
|
+
The old reference's `label` and the new reference's `heading` both return
|
|
751
775
|
`"Todos for family"`. Both still reach the migrated rows. The adapter maps
|
|
752
|
-
|
|
753
|
-
We keep
|
|
776
|
+
implementations, not references; forwarding the existing method preserves its context.
|
|
777
|
+
We keep the cell at version 2 here so this code-only change does not repeat
|
|
778
|
+
migration.
|
|
754
779
|
|
|
755
780
|
> **Why:** Contract versions protect callers; state versions protect stored
|
|
756
|
-
> data; resource
|
|
781
|
+
> data; resource versions control reopening. Do not use one number for all
|
|
757
782
|
> three. Additive compatible contracts can keep their version; breaking shape
|
|
758
783
|
> or meaning changes need a contract bump and adapters for old consumers.
|
|
759
784
|
> Reload prepares candidates first; preparation failure leaves the old code
|
|
@@ -762,13 +787,13 @@ We keep state version 2 here so this code-only change does not repeat migration.
|
|
|
762
787
|
|
|
763
788
|
## 10. Test the objects, not a whole deployment
|
|
764
789
|
|
|
765
|
-
Most
|
|
790
|
+
Most implementation needs only a bare host. Use real factories for state tests, and a
|
|
766
791
|
fake facet when you want to test delegation without a real database or service.
|
|
767
792
|
Here the fake records what the editing view sends to the store.
|
|
768
793
|
|
|
769
794
|
<!-- example:10 -->
|
|
770
795
|
```ts
|
|
771
|
-
const host = createHost({ dev: true });
|
|
796
|
+
const host = createHost<Principal>({ name: "server", self: "server", dev: true });
|
|
772
797
|
const received: string[] = [];
|
|
773
798
|
const fake: Facet = {
|
|
774
799
|
name: "fake-store",
|
|
@@ -777,14 +802,14 @@ const fake: Facet = {
|
|
|
777
802
|
...todoFactory(input, principal, self),
|
|
778
803
|
add: ({ text }) => {
|
|
779
804
|
received.push(text);
|
|
780
|
-
return null;
|
|
781
805
|
},
|
|
782
806
|
}));
|
|
783
807
|
}),
|
|
784
808
|
};
|
|
785
809
|
await host.load([fake]);
|
|
786
810
|
serveViews(host);
|
|
787
|
-
const owner = host.get(Todos, { list: "family" }, { principal: "owner" });
|
|
811
|
+
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
|
812
|
+
assert.ok(owner.variant === "editor");
|
|
788
813
|
await owner.add({ text: "Test delegation" });
|
|
789
814
|
assert.deepEqual(received, ["Test delegation"]);
|
|
790
815
|
await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid"));
|
|
@@ -793,43 +818,42 @@ await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(er
|
|
|
793
818
|
```
|
|
794
819
|
|
|
795
820
|
The fake received `["Test delegation"]`. Empty text failed before reaching
|
|
796
|
-
the fake. The released
|
|
821
|
+
the fake. The released reference rejects further calls with `released`.
|
|
797
822
|
`isChordError(error, code)` checks a stable error code; avoid testing only a
|
|
798
823
|
message's wording.
|
|
799
824
|
|
|
800
825
|
Our other tests exercise the real cells (section 3), resource cleanup (4),
|
|
801
|
-
principal variants (5), a client over a memory link (6), and callers
|
|
802
|
-
reload (9).
|
|
803
|
-
|
|
826
|
+
principal variants (5), a client over a memory link (6), and callers kept through
|
|
827
|
+
reload (9). After an awaited `get` the state is there; for later changes, await
|
|
828
|
+
the value you need. Always unsubscribe and close hosts; leaked references can hide
|
|
804
829
|
lifetime bugs or leave background tasks running.
|
|
805
830
|
|
|
806
831
|
> **Tip:** A fake must honor the contract it serves. This fake delegates all
|
|
807
832
|
> ordinary members to `todoFactory` and replaces only `add`; it deliberately
|
|
808
|
-
>
|
|
833
|
+
> objects calls rather than updating rows. Do not confuse that with persistence.
|
|
809
834
|
|
|
810
835
|
## 11. Keep these habits
|
|
811
836
|
|
|
812
|
-
A temporary call
|
|
837
|
+
A temporary call reference should not accidentally become a permanent one. A
|
|
813
838
|
screen should not lose its data merely because a button click finishes. Use
|
|
814
839
|
`ctx.get` for the first lifetime and `host.get` for the second.
|
|
815
840
|
|
|
816
841
|
<!-- example:11 -->
|
|
817
842
|
```ts
|
|
818
|
-
const host = createHost();
|
|
843
|
+
const host = createHost({ self: "server" });
|
|
819
844
|
host.serve(TodoStore, todoFactory);
|
|
820
|
-
const screen = host.get(TodoStore, { list: "family" });
|
|
821
|
-
await screen.ready;
|
|
845
|
+
const screen = await host.get(TodoStore, { list: "family" });
|
|
822
846
|
await host.root(async (ctx) => {
|
|
823
|
-
const temporary = ctx.get(TodoStore, { list: "family" });
|
|
847
|
+
const temporary = await ctx.get(TodoStore, { list: "family" });
|
|
824
848
|
await temporary.add({ text: "Keep roots short" }, ctx);
|
|
825
|
-
}); // The temporary
|
|
849
|
+
}); // The temporary reference is released here; screen still holds the store.
|
|
826
850
|
assert.equal(screen.openCount.value, 1);
|
|
827
|
-
screen.release(); // No
|
|
851
|
+
screen.release(); // No reference remains; without persistence, disposal loses these rows.
|
|
828
852
|
```
|
|
829
853
|
|
|
830
854
|
The screen still sees count `1` after the root finishes, because it owns a
|
|
831
|
-
separate
|
|
832
|
-
are in memory: reload preserves a live
|
|
855
|
+
separate reference. When the screen releases it, no reference remains. This tutorial's cells
|
|
856
|
+
are in memory: reload preserves a live object, but disposal or process restart
|
|
833
857
|
loses its rows. Real persistence belongs in a database resource, with an
|
|
834
858
|
explicit load/save policy.
|
|
835
859
|
|
|
@@ -841,17 +865,18 @@ explicit load/save policy.
|
|
|
841
865
|
can erase fields your code does not know about.
|
|
842
866
|
- Declare dependencies in a synchronous factory, not during methods: activation
|
|
843
867
|
and reload need a complete declaration list.
|
|
844
|
-
- Use `ctx.get` for temporary method dependencies: their
|
|
868
|
+
- Use `ctx.get` for temporary method dependencies: their references end with the call.
|
|
845
869
|
- Pass `ctx` into nested calls: cancellation and code versions must stay together.
|
|
846
870
|
- Keep roots short and tasks cancellable: migration and shutdown need work to settle.
|
|
847
871
|
- Check grants and arguments, not just variants: available methods are not a full policy.
|
|
848
872
|
- Stamp principals at trusted ingress, not from client payloads: names are not identities.
|
|
849
|
-
-
|
|
850
|
-
|
|
851
|
-
-
|
|
873
|
+
- Await `get` before reading state: a reference you hold is live and hydrated, a
|
|
874
|
+
loading one (`host.hold`) throws `not_ready`.
|
|
875
|
+
- Release references and unsubscribe: otherwise unused objects remain referenced or observed.
|
|
876
|
+
- Bump state versions, contract versions, and resource versions for their own changes:
|
|
852
877
|
each protects a different boundary.
|
|
853
|
-
- Await
|
|
854
|
-
- Keep
|
|
878
|
+
- Await host object readiness before reporting startup success: declared is not yet live.
|
|
879
|
+
- Keep reference dependencies acyclic: an object cannot wait for itself through another object.
|
|
855
880
|
|
|
856
881
|
### Imports and running the examples
|
|
857
882
|
|
|
@@ -869,26 +894,23 @@ import { type Static, Type } from "typebox";
|
|
|
869
894
|
import { it, onTestFinished } from "vitest";
|
|
870
895
|
import {
|
|
871
896
|
type Ctx,
|
|
872
|
-
cell,
|
|
873
897
|
createHost,
|
|
874
898
|
defineFacet,
|
|
875
899
|
defineRegistry,
|
|
876
|
-
derivation,
|
|
877
900
|
type Facet,
|
|
878
901
|
type FacetApi,
|
|
879
902
|
type Factory,
|
|
880
903
|
forbidden,
|
|
881
904
|
type Host,
|
|
882
|
-
|
|
905
|
+
hostNamed,
|
|
883
906
|
invalid,
|
|
884
907
|
isChordError,
|
|
885
|
-
listener,
|
|
886
908
|
local,
|
|
887
909
|
memoryLinkPair,
|
|
888
910
|
method,
|
|
889
911
|
opaque,
|
|
890
912
|
remote,
|
|
891
|
-
|
|
913
|
+
type StateOptions,
|
|
892
914
|
} from "../src/index.ts";
|
|
893
915
|
```
|
|
894
916
|
|