nervur 0.21.1 → 0.21.2

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/dist/proof/law.js CHANGED
@@ -7,8 +7,8 @@
7
7
  // It names no runtime, so it bundles.
8
8
  import { Being, Faculty } from '../being/index.js';
9
9
  import { Memory } from '../contract/index.js';
10
- import { copyPackage, rootLine } from '../harbor/index.js';
11
- import { CryptoEntropy, HeldLoader, World } from '../pointer/index.js';
10
+ import { copyPackage, GROUND_STORE, rootLine, Store } from '../harbor/index.js';
11
+ import { CryptoEntropy, HeldLoader, VolatileMemory, World } from '../pointer/index.js';
12
12
  class Echoes extends Faculty {
13
13
  static kind = 'org.example.echoes';
14
14
  }
@@ -52,9 +52,53 @@ class Customer extends Being {
52
52
  return { said: ((await this.stance.standings.get('echo')?.ask('echo', args)) ?? null) };
53
53
  }
54
54
  }
55
+ // A being whose fields are her cells, and nothing else.
56
+ class Profile extends Being {
57
+ static kind = 'org.example.profile';
58
+ static cells = { name: '', age: 0 };
59
+ static asks = { set: {}, get: {} };
60
+ set(args) {
61
+ if (typeof args.name === 'string')
62
+ this.cells.name = args.name;
63
+ if (typeof args.age === 'number')
64
+ this.cells.age = args.age;
65
+ return this.get();
66
+ }
67
+ get() {
68
+ return { name: this.cells.name ?? null, age: this.cells.age ?? null };
69
+ }
70
+ }
71
+ // A store a module brings: places on a shelf of this process, by the
72
+ // faculty's key, which outlive a harbor as a database outlives its client.
73
+ // A scene empties every shelf before it stands.
74
+ class Shelf extends Store {
75
+ static kind = 'org.example.shelf';
76
+ static #shelves = new Map();
77
+ static shelf(key) {
78
+ let shelf = Shelf.#shelves.get(key);
79
+ if (!shelf)
80
+ Shelf.#shelves.set(key, (shelf = new Watched(new VolatileMemory())));
81
+ return shelf;
82
+ }
83
+ static reset() {
84
+ Shelf.#shelves.clear();
85
+ }
86
+ read(place) {
87
+ return Shelf.shelf(this.stance.key).read(place);
88
+ }
89
+ write(place, entries) {
90
+ return Shelf.shelf(this.stance.key).write(place, entries);
91
+ }
92
+ places() {
93
+ return Shelf.shelf(this.stance.key).places();
94
+ }
95
+ forget(place) {
96
+ return Shelf.shelf(this.stance.key).forget(place);
97
+ }
98
+ }
55
99
  // One set of classes for every scene in a bundle, since a bundler renames
56
100
  // a second class of the same name and a row resolves a class by its name.
57
- export const classes = [Echo, Shop, Customer];
101
+ export const classes = [Echo, Shop, Customer, Profile, Shelf];
58
102
  export const law = { module: 'org.example.law', version: '1', classes };
59
103
  // A harbor of the world whose catalogue runs the law's module.
60
104
  export const standOne = async (world, name, parts = {}) => {
@@ -64,10 +108,12 @@ export const standOne = async (world, name, parts = {}) => {
64
108
  };
65
109
  const CATALOGUE = { catalogue: { class: 'org.nervur.catalogue', public: false, absent: false } };
66
110
  const NONE = { wards: {}, routes: {} };
67
- // Every byte a memory is handed, seen from outside it.
111
+ // Every byte a memory is handed, seen from outside it, and a keep it
112
+ // refuses where `refuse` says.
68
113
  class Watched extends Memory {
69
114
  inner;
70
115
  seen = [];
116
+ refuse = () => false;
71
117
  constructor(inner) {
72
118
  super();
73
119
  this.inner = inner;
@@ -76,6 +122,8 @@ class Watched extends Memory {
76
122
  return this.inner.read(place);
77
123
  }
78
124
  write(place, entries) {
125
+ if (this.refuse(place))
126
+ return Promise.reject(new Error('refused'));
79
127
  for (const [name, bytes] of entries)
80
128
  this.seen.push({ place, name, bytes: bytes && bytes.slice() });
81
129
  return this.inner.write(place, entries);
@@ -343,3 +391,201 @@ export const lawScenes = (t, expect) => [
343
391
  },
344
392
  ],
345
393
  ];
394
+ const KEEPERS = [{ name: 'the root memory' }, { name: 'a ground store', store: 'vault', kind: GROUND_STORE }, { name: 'a store of a module', store: 'shelf', kind: Shelf.kind }];
395
+ const onKeeper = (k) => (k.store === undefined ? {} : { store: k.store });
396
+ // The keeper opened in the box ward, where it is a store.
397
+ const openKeeper = async (harbor, k, expect) => {
398
+ if (k.store !== undefined)
399
+ expect.same(await harbor.root('open', { key: k.store, class: k.kind }), { opened: k.store });
400
+ };
401
+ const profile = async (harbor, method, args = {}) => (await harbor.ward('ana').root('ask', { being: 'me', method, args })).answer;
402
+ // Acme's shop kept by `k`, home's alice on the root memory, joined, and
403
+ // every box that reaches the shop's door seen.
404
+ const shopOn = async (world, t, k, expect) => {
405
+ const bodies = { acme: t.make(), home: t.make() };
406
+ const acme = await standOne(world, 'acme', bodies.acme);
407
+ const home = await standOne(world, 'home', bodies.home);
408
+ await openKeeper(acme, k, expect);
409
+ expect.same(await acme.root('host', { ward: 'shop', ...onKeeper(k) }), { hosted: 'shop' });
410
+ expect.same(await acme.ward('shop').root('boot', { key: 'shop', class: 'org.example.shop' }), { booted: 'shop' });
411
+ expect.same(await home.root('host', { ward: 'alice' }), { hosted: 'alice' });
412
+ expect.same(await home.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
413
+ const invitation = (await acme.ward('shop').root('invite', { being: 'shop', id: 'alice' }));
414
+ const shop = acme.ward('shop').pk;
415
+ const boxes = [];
416
+ const carry = world.carry.bind(world);
417
+ world.carry = (pk, bytes) => {
418
+ if (pk === shop)
419
+ boxes.push(bytes.slice());
420
+ return carry(pk, bytes);
421
+ };
422
+ const me = async (method, args = {}) => (await world.get('home').ward('alice').root('ask', { being: 'me', method, args })).answer;
423
+ expect.same(await me('join', { invitation }), { joined: 'shop' });
424
+ return { acme: bodies.acme, boxes, me };
425
+ };
426
+ const sold = (harbor) => harbor.ward('shop').partition.being('shop')?.cells.sold;
427
+ // A cell is a being's field made durable, whoever keeps it: the law's
428
+ // scenes of a being's fields, run over every keeper a hosted ward's place
429
+ // may live with.
430
+ export const keeperScenes = (t, expect) => [
431
+ ...KEEPERS.flatMap((k) => [
432
+ [
433
+ `law on ${t.name}: a being's fields kept by ${k.name} come back at wake, sealed, where that keeper alone holds them`,
434
+ async () => {
435
+ Shelf.reset();
436
+ const bodies = t.make();
437
+ const watched = new Watched(bodies.memory);
438
+ const world = new World();
439
+ const harbor = await standOne(world, 'h', { custody: bodies.custody, memory: watched });
440
+ await openKeeper(harbor, k, expect);
441
+ expect.same(await harbor.root('host', { ward: 'ana', ...onKeeper(k) }), { hosted: 'ana' });
442
+ expect.same(await harbor.ward('ana').root('boot', { key: 'me', class: Profile.kind }), { booted: 'me' });
443
+ expect.same(await profile(harbor, 'set', { name: 'Anastasia', age: 31 }), { name: 'Anastasia', age: 31 });
444
+ const again = await world.restart('h', t.again(bodies));
445
+ expect.same(await profile(again, 'get'), { name: 'Anastasia', age: 31 });
446
+ expect.same((await census(again)).failed, NONE);
447
+ const root = await bodies.memory.places();
448
+ const shelf = await Shelf.shelf('shelf').places();
449
+ if (k.store === undefined)
450
+ expect.same([root.length, shelf.length], [2, 0], 'the box ward and ana, on the root memory');
451
+ else if (k.kind === GROUND_STORE)
452
+ expect.same([root.length, root.filter((p) => p.length > 32).length, shelf.length], [2, 1, 0], 'ana behind the ground store key');
453
+ else
454
+ expect.same([root.length, shelf.length], [1, 1], 'the box ward on the root memory, ana on the shelf');
455
+ const seen = [...watched.seen, ...Shelf.shelf('shelf').seen];
456
+ for (const { bytes } of seen) {
457
+ if (bytes === null)
458
+ continue;
459
+ for (const word of ['Anastasia', 'org.example', 'vault'].map(utf8))
460
+ expect.ok(!holds(bytes, word), 'no field, kind or key in the clear');
461
+ }
462
+ },
463
+ ],
464
+ [
465
+ `law on ${t.name}: an answer whose keep ${k.name} refuses is silence, and the being keeps what she had`,
466
+ async () => {
467
+ Shelf.reset();
468
+ const bodies = t.make();
469
+ const watched = new Watched(bodies.memory);
470
+ const world = new World();
471
+ const harbor = await standOne(world, 'h', { custody: bodies.custody, memory: watched });
472
+ const box = watched.seen[0].place;
473
+ await openKeeper(harbor, k, expect);
474
+ expect.same(await harbor.root('host', { ward: 'ana', ...onKeeper(k) }), { hosted: 'ana' });
475
+ expect.same(await harbor.ward('ana').root('boot', { key: 'me', class: Profile.kind }), { booted: 'me' });
476
+ expect.same(await profile(harbor, 'set', { age: 31 }), { name: '', age: 31 });
477
+ // Only the keeper named refuses, so a keep that went elsewhere
478
+ // would answer.
479
+ if (k.store === undefined)
480
+ watched.refuse = (place) => place !== box;
481
+ else if (k.kind === GROUND_STORE)
482
+ watched.refuse = (place) => place.length > box.length;
483
+ else
484
+ Shelf.shelf('shelf').refuse = () => true;
485
+ expect.ok(JSON.stringify(await profile(harbor, 'set', { age: 32 })) !== JSON.stringify({ name: '', age: 32 }), 'no answer leaves an unkept keep');
486
+ expect.equal(harbor.ward('ana').partition.being('me')?.cells.age, 31, 'what she wrote is put back');
487
+ watched.refuse = () => false;
488
+ Shelf.shelf('shelf').refuse = () => false;
489
+ const again = await world.restart('h', t.again(bodies));
490
+ expect.same(await profile(again, 'get'), { name: '', age: 31 });
491
+ expect.same(await profile(again, 'set', { age: 32 }), { name: '', age: 32 });
492
+ },
493
+ ],
494
+ [
495
+ `law on ${t.name}: the door's keys and the being's cells are one keep with ${k.name}, so a box replayed after a wake is answered once`,
496
+ async () => {
497
+ Shelf.reset();
498
+ const world = new World();
499
+ const { acme, boxes, me } = await shopOn(world, t, k, expect);
500
+ const from = boxes.length;
501
+ expect.same(await me('buy', { item: 'tea' }), { said: { bought: 'tea' } });
502
+ const replayed = boxes.slice(from);
503
+ expect.ok(replayed.length > 0, 'the buy crossed the door');
504
+ const again = await world.restart('acme', t.again(acme));
505
+ expect.same(sold(again), ['tea to alice']);
506
+ for (const bytes of replayed)
507
+ await again.arrive(again.ward('shop').pk, bytes);
508
+ expect.same(sold(again), ['tea to alice'], 'a replayed box does not buy again');
509
+ expect.same(await me('buy', { item: 'cake' }), { said: { bought: 'cake' } });
510
+ expect.same(sold(world.get('acme')), ['tea to alice', 'cake to alice']);
511
+ },
512
+ ],
513
+ ]),
514
+ [
515
+ `law on ${t.name}: a ward whose store does not stand at wake fails alone, and stands whole once it returns`,
516
+ async () => {
517
+ Shelf.reset();
518
+ const bodies = t.make();
519
+ const world = new World();
520
+ const harbor = await standOne(world, 'h', bodies);
521
+ await openKeeper(harbor, KEEPERS[2], expect);
522
+ expect.same(await harbor.root('host', { ward: 'ana', store: 'shelf' }), { hosted: 'ana' });
523
+ expect.same(await harbor.root('host', { ward: 'bea' }), { hosted: 'bea' });
524
+ expect.same(await harbor.ward('ana').root('boot', { key: 'me', class: Profile.kind }), { booted: 'me' });
525
+ expect.same(await profile(harbor, 'set', { age: 31 }), { name: '', age: 31 });
526
+ const lost = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([]) });
527
+ expect.same(lost.names, ['bea'], 'the root and its own ward stand');
528
+ expect.same((await census(lost)).failed, { wards: { ana: 'the store shelf does not stand' }, routes: {} });
529
+ const back = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law]) });
530
+ expect.same(back.names, ['ana', 'bea']);
531
+ expect.same(await profile(back, 'get'), { name: '', age: 31 });
532
+ },
533
+ ],
534
+ [
535
+ `law on ${t.name}: a ward carried between keepers keeps its fields, its door and its key, and leaves nothing where it was`,
536
+ async () => {
537
+ Shelf.reset();
538
+ const world = new World();
539
+ const { acme, me } = await shopOn(world, t, KEEPERS[0], expect);
540
+ const harbor = world.get('acme');
541
+ for (const k of KEEPERS.slice(1))
542
+ await openKeeper(harbor, k, expect);
543
+ const pk = harbor.ward('shop').pk;
544
+ const items = ['tea', 'cake', 'bread', 'milk'];
545
+ const vault = harbor.box.being('vault');
546
+ const counts = async () => [(await acme.memory.places()).filter((p) => p.length === 32).length, (await vault.places()).length, (await Shelf.shelf('shelf').places()).length];
547
+ expect.same(await me('buy', { item: items[0] }), { said: { bought: items[0] } });
548
+ const road = [
549
+ [KEEPERS[1], [1, 1, 0]],
550
+ [KEEPERS[2], [1, 0, 1]],
551
+ [KEEPERS[0], [2, 0, 0]],
552
+ ];
553
+ for (const [i, [k, want]] of road.entries()) {
554
+ expect.same(await world.get('acme').root('move', { ward: 'shop', store: k.store ?? null }), { moved: 'shop', store: k.store ?? null });
555
+ expect.same(await counts(), want, `only ${k.name} holds the shop`);
556
+ const again = await world.restart('acme', t.again(acme));
557
+ expect.equal(again.ward('shop').pk, pk, 'the key moves with the ward');
558
+ expect.same(await me('buy', { item: items[i + 1] }), { said: { bought: items[i + 1] } });
559
+ expect.same(sold(again), items.slice(0, i + 2).map((item) => `${item} to alice`));
560
+ }
561
+ expect.same(await world.get('acme').root('move', { ward: 'shop', store: null }), { error: 'shop is kept there already' });
562
+ expect.same(await world.get('acme').root('move', { ward: 'shop', store: 'echo' }), { error: 'no such store' });
563
+ },
564
+ ],
565
+ [
566
+ `law on ${t.name}: a store keeping a ward does not go, and the root sweeps what no keeper is named for`,
567
+ async () => {
568
+ Shelf.reset();
569
+ const bodies = t.make();
570
+ const world = new World();
571
+ const harbor = await standOne(world, 'h', bodies);
572
+ for (const k of KEEPERS.slice(1))
573
+ await openKeeper(harbor, k, expect);
574
+ expect.same(await harbor.root('host', { ward: 'ana', store: 'vault' }), { hosted: 'ana' });
575
+ expect.same(await harbor.root('host', { ward: 'bea', store: 'shelf' }), { hosted: 'bea' });
576
+ expect.same(await harbor.root('host', { ward: 'cy', store: 'nothing' }), { error: 'no such store' });
577
+ expect.same(await harbor.root('unboot', { being: 'vault' }), { error: 'a ward is kept by this store' });
578
+ const vault = harbor.box.being('vault');
579
+ const before = [(await bodies.memory.places()).sort(), await vault.places(), await Shelf.shelf('shelf').places()];
580
+ const stray = 'ee'.repeat(16);
581
+ await vault.write(stray, new Map([['ab', new Uint8Array([1])]]));
582
+ await Shelf.shelf('shelf').write(stray, new Map([['ab', new Uint8Array([2])]]));
583
+ expect.same(await harbor.root('sweep'), { swept: [stray, stray] });
584
+ expect.same([(await bodies.memory.places()).sort(), await vault.places(), await Shelf.shelf('shelf').places()], before, 'what the dock names stays');
585
+ expect.same(await harbor.root('move', { ward: 'bea', store: null }), { moved: 'bea', store: null });
586
+ expect.same(await harbor.root('unboot', { being: 'shelf' }), { unbooted: 'shelf' });
587
+ const again = await world.restart('h', t.again(bodies));
588
+ expect.same(again.names, ['ana', 'bea']);
589
+ },
590
+ ],
591
+ ];
package/dist/ward/ward.js CHANGED
@@ -98,23 +98,29 @@ export class Ward {
98
98
  }
99
99
  }
100
100
  // The unsealed ask: the ward-being, asked by whoever holds this ward.
101
+ // It reaches every being of the ward through the ward-being, so every
102
+ // row is put back where the memory refuses.
101
103
  async root(method, args = {}) {
102
- return this.#kept(WARD, () => this.#answer(WARD, { id: OWNER }, method, args));
104
+ return this.#kept(Object.keys(this.partition.beings), () => this.#answer(WARD, { id: OWNER }, method, args));
103
105
  }
104
- // A being's answer, kept. Where the memory refuses, what she wrote is
105
- // put back and her answer is silence. What her relations stand on is not
106
+ // An answer, kept. Where the memory refuses, what the beings wrote is put
107
+ // back and the answer is silence. What their relations stand on is not
106
108
  // put back: a relation's keys move only as far as its standing follows.
107
- async #kept(key, work) {
108
- const row = this.partition.being(key);
109
- const before = row && JSON.stringify(row.cells);
109
+ async #kept(keys, work) {
110
+ const before = keys.flatMap((key) => {
111
+ const row = this.partition.being(key);
112
+ return row ? [[key, row.cells, JSON.stringify(row.cells)]] : [];
113
+ });
110
114
  const out = await work();
111
115
  if (await this.partition.kept())
112
116
  return out;
113
- if (row && before !== undefined) {
114
- for (const k of Object.keys(row.cells))
115
- delete row.cells[k];
116
- for (const [k, v] of Object.entries(JSON.parse(before)))
117
- Object.defineProperty(row.cells, k, { value: v, enumerable: true, writable: true, configurable: true });
117
+ for (const [key, cells, was] of before) {
118
+ if (JSON.stringify(cells) === was)
119
+ continue;
120
+ for (const k of Object.keys(cells))
121
+ delete cells[k];
122
+ for (const [k, v] of Object.entries(JSON.parse(was)))
123
+ Object.defineProperty(cells, k, { value: v, enumerable: true, writable: true, configurable: true });
118
124
  this.partition.told(key);
119
125
  }
120
126
  return silence;
@@ -132,7 +138,7 @@ export class Ward {
132
138
  const args = p.args === undefined ? {} : readValue(p.args, DEPTH + 1);
133
139
  if (key === null || key === undefined || typeof args !== 'object' || args === null || Array.isArray(args))
134
140
  return { silence: true };
135
- const out = await this.#kept(key, () => this.#answer(key, asker, p.method, args));
141
+ const out = await this.#kept([key], () => this.#answer(key, asker, p.method, args));
136
142
  const object = isSilence(out) || isWord(out) ? undefined : writeValue(out, DEPTH);
137
143
  if (object === undefined)
138
144
  return { silence: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Nervur's kit of Quo: an object asks another object and gets an answer, without knowing where it is.",
5
5
  "keywords": [
6
6
  "quo",
@@ -28,7 +28,10 @@
28
28
  // dev, from this folder, at
29
29
  // http://127.0.0.1:P/quo
30
30
  // nervur open <key> <kind> a faculty stands in the box ward
31
- // nervur host <ward> [--seed S] a hosted ward stands
31
+ // nervur host <ward> [--seed S] [--store K] a hosted ward stands, kept by the
32
+ // root memory or the store K
33
+ // nervur move <ward> <store | root> a hosted ward carried to a store,
34
+ // or back to the root memory
32
35
  // nervur route <ward pk> <address... | none> where a ward is reached, in order
33
36
  // nervur reach <address...> where this harbor is reached, as
34
37
  // every invitation it gives says
@@ -63,8 +66,8 @@ import { carry, isServed, land, open, pack, request, serve } from './harbor.ts';
63
66
  // Where `edge dev` answers unless --port names another, wrangler's own.
64
67
  const EDGE_PORT = 8787;
65
68
 
66
- const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import|edge> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
67
- const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
69
+ const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|move|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import|edge> [args] [--dir D] [--ward W] [--via V] [--seed S] [--store K] [--with-seed] [--host H] [--port P]';
70
+ const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'store', 'host', 'port']);
68
71
  // The one flag that stands alone, since what it names is a yes.
69
72
  const ALONE = 'with-seed';
70
73
 
@@ -120,7 +123,11 @@ export const requestOf = (command: string, words: string[], flags: Record<string
120
123
  }
121
124
  case 'host': {
122
125
  const [ward] = need(1);
123
- return { method: 'host', args: { ward: ward!, ...(flags.seed === undefined ? {} : { seed: flags.seed }) } };
126
+ return { method: 'host', args: { ward: ward!, ...(flags.seed === undefined ? {} : { seed: flags.seed }), ...(flags.store === undefined ? {} : { store: flags.store }) } };
127
+ }
128
+ case 'move': {
129
+ const [ward, store] = need(2);
130
+ return { method: 'move', args: { ward: ward!, store: store === 'root' ? null : store! } };
124
131
  }
125
132
  case 'route': {
126
133
  const [ward, ...where] = words;
@@ -5,9 +5,14 @@
5
5
  //
6
6
  // open { key, class } a faculty stands in the box ward, and the
7
7
  // dock holds a standing on her under her key
8
- // host { ward, seed? } a hosted ward stands under the seed named,
9
- // or a drawn one, holding a standing on the
10
- // dock; the dock keeps its name and seed
8
+ // host { ward, seed?, a hosted ward stands under the seed named,
9
+ // store? } or a drawn one, holding a standing on the
10
+ // dock; the dock keeps its name, its seed and
11
+ // the store faculty that keeps its place, or
12
+ // none for the root memory
13
+ // move { ward, store } a hosted ward's place carried whole to
14
+ // another keeper, a store's key or null for
15
+ // the root memory, and forgotten where it was
11
16
  // lend { contract } asked by a hosted ward: an offer from a
12
17
  // faculty that fulfils the contract
13
18
  // retract { contract, heir } an offer that was not taken
@@ -22,13 +27,19 @@
22
27
  // route, where the root set none.
23
28
  import { Faculty, KIT, type Asker, type AskSpec, type Blueprint, type Invitation, type JsonObject } from '../being/index.ts';
24
29
  import { CATALOGUE } from './catalogue.ts';
30
+ import { STORE } from './store.ts';
25
31
  import { readAt, readInvitation } from '../quo/index.ts';
26
32
  import { ownerAsk, pilots, rootAsk, WardBeing, type Lender, type WardBeingStance } from '../ward/index.ts';
27
33
 
28
34
  // What the dock asks of her harbor: a ward standing, and the seed, as hex,
29
35
  // it stands under.
30
36
  export interface Hosting {
31
- host(name: string, invitation: Invitation, seed?: string): Promise<string | null>;
37
+ host(name: string, invitation: Invitation, seed?: string, store?: string): Promise<string | null>;
38
+ // A standing ward's place, carried to another keeper and standing from
39
+ // there: null, or why not. Where it was stays until `release`.
40
+ move(name: string, seed: string, from: string | undefined, to: string | undefined): Promise<string | null>;
41
+ // A ward's place, forgotten by the keeper named.
42
+ release(name: string, store: string | undefined): Promise<void>;
32
43
  // Whether the harbor's carrier took the route.
33
44
  route(pk: string, at: readonly string[] | null): boolean;
34
45
  // Whether the box ward's partition was kept.
@@ -45,8 +56,9 @@ export interface Hosting {
45
56
  lull(key: string): Promise<void>;
46
57
  }
47
58
 
48
- // One hosted ward, as the dock's cells keep it.
49
- export type Hosted = { ward: string; seed: string };
59
+ // One hosted ward, as the dock's cells keep it, with the key of the store
60
+ // that keeps its place where the root memory does not.
61
+ export type Hosted = { ward: string; seed: string; store?: string };
50
62
 
51
63
  // Each hosted ward that did not unpack and each route the carrier did not
52
64
  // take, with the reason, by name and by ward pk.
@@ -64,7 +76,8 @@ export class Dock extends WardBeing implements Lender {
64
76
  static override asks: Record<string, AskSpec> = {
65
77
  ...WardBeing.asks,
66
78
  open: ownerAsk('a faculty of this harbor', { required: ['key', 'class'] }),
67
- host: ownerAsk('a ward this harbor hosts', { required: ['ward'] }),
79
+ host: ownerAsk('a ward this harbor hosts, kept by the root memory or by the store named', { required: ['ward'] }),
80
+ move: ownerAsk('a hosted ward carried to a store, or to the root memory with null', { required: ['ward', 'store'] }),
68
81
  route: ownerAsk('where a ward is reached, addresses in order, or null', { required: ['ward', 'at'] }),
69
82
  reach: ownerAsk('where this harbor is reached, the addresses every invitation carries', { required: ['at'] }),
70
83
  sweep: rootAsk('every place of the memory this harbor does not name, gone'),
@@ -87,7 +100,12 @@ export class Dock extends WardBeing implements Lender {
87
100
  }
88
101
 
89
102
  get hosted(): Hosted[] {
90
- return (this.cells.hosted as Hosted[]).map(({ ward, seed }) => ({ ward, seed }));
103
+ return (this.cells.hosted as Hosted[]).map(({ ward, seed, store }) => ({ ward, seed, ...(store === undefined ? {} : { store }) }));
104
+ }
105
+
106
+ // Whether a key names a store faculty the dock opened.
107
+ #store(key: unknown): key is string {
108
+ return text(key) && this.#fulfilling(STORE).includes(key);
91
109
  }
92
110
 
93
111
  get routes(): Record<string, string[]> {
@@ -130,8 +148,10 @@ export class Dock extends WardBeing implements Lender {
130
148
  if ((await this.stance.boot(kind, CATALOGUE, CATALOGUE)) === null || !(await this.#hosting.keep())) throw new Error('the catalogue did not stand');
131
149
  }
132
150
 
133
- // A faculty that goes stops listening first.
151
+ // A faculty that goes stops listening first, and a store keeping a
152
+ // ward's place does not go.
134
153
  override async unboot(args: JsonObject): Promise<JsonObject> {
154
+ if (this.hosted.some((h) => h.store !== undefined && h.store === args.being)) return { error: 'a ward is kept by this store' };
135
155
  if (typeof args.being === 'string') await this.#hosting.lull(args.being);
136
156
  return super.unboot(args);
137
157
  }
@@ -162,17 +182,46 @@ export class Dock extends WardBeing implements Lender {
162
182
  if (name === BOX) return { error: 'box is the box ward' };
163
183
  if (this.hosted.some((h) => h.ward === name)) return { error: `${name} is hosted already` };
164
184
  if (args.seed !== undefined && !text(args.seed)) return { error: 'seed is text' };
185
+ const store = args.store ?? undefined;
186
+ if (store !== undefined && !this.#store(store)) return { error: 'no such store' };
165
187
  const invitation = await this.stance.occupants.invite(name, { hosted: true });
166
188
  if (invitation === null) return { error: 'not hosted' };
167
- const seed = await this.#hosting.host(name, invitation, args.seed);
189
+ const seed = await this.#hosting.host(name, invitation, args.seed, store);
168
190
  if (seed === null) {
169
191
  this.stance.occupants.remove(name);
170
192
  return { error: 'not hosted' };
171
193
  }
172
- (this.cells.hosted as Hosted[]).push({ ward: name, seed });
194
+ (this.cells.hosted as Hosted[]).push({ ward: name, seed, ...(store === undefined ? {} : { store }) });
173
195
  return { hosted: name };
174
196
  }
175
197
 
198
+ // The place is written where it goes before the dock's cells say so, and
199
+ // forgotten where it was only once they are kept, so a cut at any step
200
+ // leaves the ward whole under the keeper the cells name.
201
+ async move(args: JsonObject): Promise<JsonObject> {
202
+ const rows = this.cells.hosted as Hosted[];
203
+ const row = rows.find((h) => h.ward === args.ward);
204
+ if (!row) return { error: 'no such ward' };
205
+ const to = args.store ?? undefined;
206
+ if (to !== undefined && !this.#store(to)) return { error: 'no such store' };
207
+ const from = row.store;
208
+ if (from === to) return { error: `${row.ward} is kept there already` };
209
+ const refused = await this.#hosting.move(row.ward, row.seed, from, to);
210
+ if (refused !== null) return { error: refused };
211
+ const set = (store: string | undefined): void => {
212
+ if (store === undefined) delete row.store;
213
+ else row.store = store;
214
+ };
215
+ set(to);
216
+ if (!(await this.#hosting.keep())) {
217
+ set(from);
218
+ await this.#hosting.move(row.ward, row.seed, to, from);
219
+ return { error: 'not kept' };
220
+ }
221
+ await this.#hosting.release(row.ward, from);
222
+ return { moved: row.ward, store: to ?? null };
223
+ }
224
+
176
225
  async lend(args: JsonObject): Promise<JsonObject> {
177
226
  if (!text(args.contract)) return { error: 'contract is text' };
178
227
  const invitation = await this.offer(args.contract);