@quo-systems/quo 0.1.1 → 0.2.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/SPEC.md +185 -114
- package/dist/being/index.d.ts +1 -1
- package/dist/being/index.js +1 -1
- package/dist/being/silence.d.ts +6 -1
- package/dist/being/silence.js +25 -7
- package/dist/being/types.d.ts +8 -4
- package/dist/being/types.js +1 -1
- package/dist/conformance/beings.d.ts +1 -1
- package/dist/conformance/beings.js +5 -5
- package/dist/conformance/estate.js +12 -6
- package/dist/conformance/index.js +14 -12
- package/dist/harbor/core.d.ts +6 -1
- package/dist/harbor/core.js +11 -2
- package/dist/harbor/memory.d.ts +5 -1
- package/dist/harbor/memory.js +7 -2
- package/dist/ward/door.d.ts +6 -2
- package/dist/ward/door.js +55 -35
- package/dist/ward/ground.d.ts +4 -1
- package/dist/ward/heirs.d.ts +4 -2
- package/dist/ward/heirs.js +20 -3
- package/dist/ward/owner.js +6 -4
- package/dist/ward/partition.d.ts +6 -0
- package/dist/ward/partition.js +2 -0
- package/dist/ward/seal.d.ts +3 -1
- package/dist/ward/seal.js +6 -2
- package/dist/ward/stance.d.ts +2 -2
- package/dist/ward/stance.js +21 -12
- package/dist/ward/ward.js +7 -7
- package/package.json +19 -31
- package/src/being/index.ts +1 -1
- package/src/being/silence.ts +26 -8
- package/src/being/types.ts +13 -3
- package/src/conformance/beings.ts +5 -5
- package/src/conformance/estate.ts +11 -6
- package/src/conformance/index.ts +14 -12
- package/src/harbor/core.ts +11 -2
- package/src/harbor/memory.ts +6 -1
- package/src/ward/door.ts +54 -32
- package/src/ward/ground.ts +6 -2
- package/src/ward/heirs.ts +20 -5
- package/src/ward/owner.ts +4 -3
- package/src/ward/partition.ts +10 -0
- package/src/ward/seal.ts +8 -5
- package/src/ward/stance.ts +20 -14
- package/src/ward/ward.ts +9 -9
- package/vectors/framing.json +7 -1
package/dist/ward/seal.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDoorWord } from '../being/silence.js';
|
|
1
2
|
import { KEY, SIGNATURE, box, concat, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.js';
|
|
2
3
|
const utf8 = new TextEncoder();
|
|
3
4
|
const text = new TextDecoder();
|
|
@@ -73,8 +74,9 @@ export async function sealReply(reply, ephemeralPk, wardSign, seed) {
|
|
|
73
74
|
// Open a reply with the ephemeral secret kept from the ask, and verify it
|
|
74
75
|
// came from the ward it was sent to. Null is silence: it did not open, it
|
|
75
76
|
// lied, or it is not a reply. Every ward on the wire is somebody else's code,
|
|
76
|
-
// so the shape is checked here and nowhere later: a reply is `{ silence }
|
|
77
|
-
//
|
|
77
|
+
// so the shape is checked here and nowhere later: a reply is `{ silence }`,
|
|
78
|
+
// `{ quo: word }` with a word the door may say, or `{ object, seen }` with an
|
|
79
|
+
// object present and seen a digest or null.
|
|
78
80
|
// Anything else is bytes that said nothing, and her ward never hands her a
|
|
79
81
|
// throw or an absent value for what a stranger wrote.
|
|
80
82
|
export async function openReply(bytes, ephemeralSecret, signPk) {
|
|
@@ -93,6 +95,8 @@ export async function openReply(bytes, ephemeralSecret, signPk) {
|
|
|
93
95
|
const r = reply;
|
|
94
96
|
if (r.silence === true)
|
|
95
97
|
return { silence: true };
|
|
98
|
+
if (Object.hasOwn(r, 'quo'))
|
|
99
|
+
return isDoorWord(r.quo) ? { quo: r.quo } : null;
|
|
96
100
|
if (!Object.hasOwn(r, 'object') || r.object === undefined)
|
|
97
101
|
return null;
|
|
98
102
|
if (r.seen !== null && !(typeof r.seen === 'string' && /^[0-9a-f]{64}$/.test(r.seen)))
|
package/dist/ward/stance.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Cells, JsonObject, Silence, Stance,
|
|
1
|
+
import type { Cells, JsonObject, Silence, Stance, Wanted, Word } from '../being/types.ts';
|
|
2
2
|
import { type Bind, type StandingKeys } from './partition.ts';
|
|
3
3
|
import { type ReplyPayload } from './seal.ts';
|
|
4
4
|
export type Inside = {
|
|
@@ -9,7 +9,7 @@ export type Inside = {
|
|
|
9
9
|
}>;
|
|
10
10
|
openHeir(heir: string, being: string, id: string): void;
|
|
11
11
|
closeHeir(heir: string): void;
|
|
12
|
-
send(bind: Bind, keys: StandingKeys, announce: boolean, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence |
|
|
12
|
+
send(bind: Bind, keys: StandingKeys, announce: boolean, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence | Word>;
|
|
13
13
|
};
|
|
14
14
|
export type SealedInvitation = {
|
|
15
15
|
ward: string;
|
package/dist/ward/stance.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// The one stance builder. Used for every being the ward boots. Nothing
|
|
3
3
|
// outer is in what she holds: ids in, values out, keys in the bind table.
|
|
4
|
-
import { silence, isSilence, isUnreached } from '../being/silence.js';
|
|
4
|
+
import { silence, isSilence, isUnreached, isWord, word } from '../being/silence.js';
|
|
5
5
|
import { digest } from '../being/digest.js';
|
|
6
6
|
import { RESERVED_IDS } from '../being/types.js';
|
|
7
7
|
import { at, put, drop } from './partition.js';
|
|
@@ -65,14 +65,16 @@ export function buildStance(inside, key, cells, bind) {
|
|
|
65
65
|
const out = await within(allow(wanted).time, work(() => !late), () => {
|
|
66
66
|
late = true;
|
|
67
67
|
});
|
|
68
|
-
return out === LATE ?
|
|
68
|
+
return out === LATE ? word('late') : out;
|
|
69
69
|
};
|
|
70
70
|
const send = async (keys, method, args, wanted, rec) => {
|
|
71
71
|
const r = await inside.send(bind, keys, true, method, args, wanted);
|
|
72
|
-
if (isSilence(r) ||
|
|
72
|
+
if (isSilence(r) || isWord(r))
|
|
73
73
|
return r;
|
|
74
74
|
if ('silence' in r)
|
|
75
75
|
return silence;
|
|
76
|
+
if ('quo' in r)
|
|
77
|
+
return word(r.quo); // the far door's word for her, sealed to her lid
|
|
76
78
|
if (rec && method !== undefined && r.seen !== null)
|
|
77
79
|
rec.seen = r.seen; // the digest rode along
|
|
78
80
|
return r.object;
|
|
@@ -105,7 +107,7 @@ export function buildStance(inside, key, cells, bind) {
|
|
|
105
107
|
k.next = own.next;
|
|
106
108
|
if (isUnreached(r))
|
|
107
109
|
return r; // still nothing. she knows no more than before.
|
|
108
|
-
if (!isSilence(r) && !('
|
|
110
|
+
if (!isSilence(r) && !isWord(r) && 'object' in r) {
|
|
109
111
|
k.spoke = true; // it was heard the first time, and this is the answer
|
|
110
112
|
bind.answered[name] = true;
|
|
111
113
|
return r.object;
|
|
@@ -127,9 +129,16 @@ export function buildStance(inside, key, cells, bind) {
|
|
|
127
129
|
}
|
|
128
130
|
if (isUnreached(r))
|
|
129
131
|
return r; // nothing came back. she does not know whether it was heard.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
// The heir is spent once the door honoured it, whatever she then said: an
|
|
133
|
+
// object, silence, or that she threw. A refusal wrote nothing, and says
|
|
134
|
+
// so by its word, so the heir still speaks and the next knock is as it.
|
|
135
|
+
k.spoke = !(!isSilence(r) && !isWord(r) && 'quo' in r && r.quo !== 'threw');
|
|
136
|
+
if (isSilence(r) || isWord(r))
|
|
137
|
+
return r;
|
|
138
|
+
if ('silence' in r)
|
|
132
139
|
return silence;
|
|
140
|
+
if ('quo' in r)
|
|
141
|
+
return word(r.quo);
|
|
133
142
|
bind.answered[name] = true;
|
|
134
143
|
return r.object;
|
|
135
144
|
};
|
|
@@ -141,15 +150,15 @@ export function buildStance(inside, key, cells, bind) {
|
|
|
141
150
|
const calls = {
|
|
142
151
|
knock: async (inv, method, args = {}, wanted) => {
|
|
143
152
|
if (!valid(inv))
|
|
144
|
-
return
|
|
153
|
+
return word('invitation'); // S1. nothing is sent
|
|
145
154
|
// She may knock again, and after take that knock is an ask: the relation
|
|
146
155
|
// has one home, so it answers on the standing's lane, under the
|
|
147
156
|
// standing's keys and its count. A second lane here would speak for the
|
|
148
157
|
// same relation, and the two would refuse each other.
|
|
149
158
|
const id = takenAs(nameOf(inv));
|
|
150
159
|
if (id !== undefined)
|
|
151
|
-
return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, bind.standings[id], cells.standings[id], method, args, wanted) : Promise.resolve(
|
|
152
|
-
return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted) : Promise.resolve(
|
|
160
|
+
return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, bind.standings[id], cells.standings[id], method, args, wanted) : Promise.resolve(word('late')))));
|
|
161
|
+
return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted) : Promise.resolve(word('late')))));
|
|
153
162
|
},
|
|
154
163
|
// Take consumes. Until now the relation lived in the knock record, under
|
|
155
164
|
// the ward and the heir; from now it lives in the standing, under her id.
|
|
@@ -201,14 +210,14 @@ export function buildStance(inside, key, cells, bind) {
|
|
|
201
210
|
ask: (method, args = {}, wanted) => {
|
|
202
211
|
const keys = at(bind.standings, id);
|
|
203
212
|
if (!keys)
|
|
204
|
-
return Promise.resolve(
|
|
213
|
+
return Promise.resolve(word('dropped')); // S2. she dropped it between one line and the next
|
|
205
214
|
const rec = at(cells.standings, id);
|
|
206
|
-
return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(
|
|
215
|
+
return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(word('late')))));
|
|
207
216
|
},
|
|
208
217
|
});
|
|
209
218
|
const asking = async (id, keys, rec, method, args, wanted) => {
|
|
210
219
|
const out = await send(keys, method, args, wanted, rec);
|
|
211
|
-
if (method === undefined && !isSilence(out) && !
|
|
220
|
+
if (method === undefined && !isSilence(out) && !isWord(out)) {
|
|
212
221
|
const here = at(cells.standings, id);
|
|
213
222
|
if (here) {
|
|
214
223
|
here.blueprint = out;
|
package/dist/ward/ward.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the first being in its own map, and every being it later instantiates gets
|
|
5
5
|
// a stance built by the same code. Mints every key, seals every ask that
|
|
6
6
|
// leaves, opens every one that arrives.
|
|
7
|
-
import { silence, isSilence, unreached,
|
|
7
|
+
import { silence, isSilence, unreached, isWord, word } from '../being/silence.js';
|
|
8
8
|
import { OWNER } from '../being/types.js';
|
|
9
9
|
import { at, open, put, emptyBind, emptyCells } from './partition.js';
|
|
10
10
|
import { Heirs } from './heirs.js';
|
|
@@ -65,7 +65,7 @@ class Self {
|
|
|
65
65
|
this.ask = async (method, args = {}) => {
|
|
66
66
|
try {
|
|
67
67
|
const out = await this.answer({ id: OWNER }, method, args);
|
|
68
|
-
return isSilence(out) ||
|
|
68
|
+
return isSilence(out) || isWord(out) ? silence : out;
|
|
69
69
|
}
|
|
70
70
|
catch {
|
|
71
71
|
return silence;
|
|
@@ -173,22 +173,22 @@ class Self {
|
|
|
173
173
|
// relation that comes back round holds a lane the answer needs, and only a
|
|
174
174
|
// bound on the wait can break that. What comes back late is not read.
|
|
175
175
|
//
|
|
176
|
-
// A wait that ran out is
|
|
176
|
+
// A wait that ran out is `late`, never unreached. Unreached promises
|
|
177
177
|
// nothing was delivered and is safe to retry; a bound that expired knows
|
|
178
178
|
// no such thing, because the far door may have heard and be working still.
|
|
179
179
|
//
|
|
180
180
|
// A harbor answers bytes or nothing. One that throws instead has answered
|
|
181
181
|
// nothing in a louder voice, and is read as nothing: no door was reached.
|
|
182
|
-
const carried = (keys.ward === this.pk ? this.door(new Uint8Array(bytes)) : this.g.carry(keys.ward, bytes)).then((b) => b, () => undefined);
|
|
182
|
+
const carried = (keys.ward === this.pk ? this.door(new Uint8Array(bytes)).then((r) => r.bytes) : this.g.carry(keys.ward, bytes)).then((b) => b, () => undefined);
|
|
183
183
|
const out = await within(budget.time, carried);
|
|
184
184
|
if (out === LATE)
|
|
185
|
-
return
|
|
185
|
+
return word('late');
|
|
186
186
|
if (out === undefined)
|
|
187
187
|
return unreached(); // nothing came back. no door was reached.
|
|
188
188
|
const reply = await openReply(out, ephemeral, wardSignPk(keys.ward));
|
|
189
189
|
if (!reply)
|
|
190
|
-
return silence; // did not open, or not from that ward
|
|
191
|
-
if (
|
|
190
|
+
return silence; // did not open, or not from that ward: not Quo's bytes
|
|
191
|
+
if ('object' in reply && keys.next !== null) {
|
|
192
192
|
keys.current = keys.next; // the far door holds `next` as announced. move to it.
|
|
193
193
|
keys.next = null;
|
|
194
194
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/quo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Quo: an object asks another object and gets an answer, without knowing where it is. Being, Ward, Harbor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -18,40 +18,28 @@
|
|
|
18
18
|
"node": ">=22.18"
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
|
-
".": {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/being/index.d.ts",
|
|
23
|
+
"default": "./dist/being/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./ward": {
|
|
26
|
+
"types": "./dist/ward/index.d.ts",
|
|
27
|
+
"default": "./dist/ward/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./harbor": {
|
|
30
|
+
"types": "./dist/harbor/index.d.ts",
|
|
31
|
+
"default": "./dist/harbor/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./conformance": {
|
|
34
|
+
"types": "./dist/conformance/index.d.ts",
|
|
35
|
+
"default": "./dist/conformance/index.js"
|
|
36
|
+
}
|
|
25
37
|
},
|
|
26
38
|
"scripts": {
|
|
27
39
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
28
|
-
"check": "npm run build && npm run typecheck && npm run lint && npm test && npm run check:estate",
|
|
29
|
-
"check:estate": "node --test \"estate/test/*.test.ts\"",
|
|
30
|
-
"pretypecheck": "npm run build",
|
|
31
|
-
"typecheck": "tsc",
|
|
32
|
-
"typecheck:watch": "tsc --watch",
|
|
33
|
-
"lint": "npm run lint:ts && npm run lint:md",
|
|
34
|
-
"lint:ts": "oxlint --type-aware",
|
|
35
|
-
"lint:md": "markdownlint-cli2",
|
|
36
|
-
"lint:fix": "oxlint --type-aware --fix && markdownlint-cli2 --fix",
|
|
37
|
-
"pretest": "npm run build",
|
|
38
40
|
"test": "node --test \"test/*.test.ts\"",
|
|
39
|
-
"check:terrain": "node --test \"test/terrain/*.test.ts\"
|
|
40
|
-
"
|
|
41
|
-
"test:ward": "node --test test/ward.test.ts",
|
|
42
|
-
"prepublishOnly": "npm run check && npm run check:terrain"
|
|
43
|
-
},
|
|
44
|
-
"devDependencies": {
|
|
45
|
-
"@types/node": "^26.4.1",
|
|
46
|
-
"bun": "^1.4.0",
|
|
47
|
-
"deno": "^2.9.6",
|
|
48
|
-
"esbuild": "^0.28.2",
|
|
49
|
-
"markdownlint-cli2": "^0.23.2",
|
|
50
|
-
"oxlint": "^1.81.0",
|
|
51
|
-
"oxlint-tsgolint": "^7.0.2001",
|
|
52
|
-
"playwright": "^1.62.1",
|
|
53
|
-
"typescript": "^7.0.2",
|
|
54
|
-
"workerd": "^1.20260904.1"
|
|
41
|
+
"check:terrain": "node --test \"test/terrain/*.test.ts\"",
|
|
42
|
+
"prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
|
|
55
43
|
},
|
|
56
44
|
"publishConfig": {
|
|
57
45
|
"access": "public"
|
package/src/being/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// @quo-systems/quo — the Being side. What a being author imports, if anything.
|
|
3
3
|
export { Being, type AskSpec } from './being.ts';
|
|
4
|
-
export { silence, isSilence, unreached, isUnreached } from './silence.ts';
|
|
4
|
+
export { silence, isSilence, unreached, isUnreached, word, isWord, wordOf, DOOR_WORDS, isDoorWord } from './silence.ts';
|
|
5
5
|
export { digest, canonical } from './digest.ts';
|
|
6
6
|
export { OWNER, PUBLIC, RESERVED_IDS } from './types.ts';
|
|
7
7
|
export type * from './types.ts';
|
package/src/being/silence.ts
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Silence is one distinguished value. Null is an answer. Silence is no answer.
|
|
3
|
-
|
|
3
|
+
//
|
|
4
|
+
// The ward's words are the other distinguished values: what her ward tells
|
|
5
|
+
// her when no object came back and it knows why. Each is one frozen object
|
|
6
|
+
// under one symbol key, so a being cannot make one by accident, none of them
|
|
7
|
+
// crosses an edge as a value, and a being who compares against them needs
|
|
8
|
+
// no fourth shape. `unreached` is one of them.
|
|
9
|
+
import { WORD_KEY, type DoorWord, type Unreached, type Word, type WordName } from './types.ts';
|
|
4
10
|
|
|
5
11
|
export const silence: unique symbol = Symbol.for('quo.silence');
|
|
6
12
|
export const isSilence = (x: unknown): x is typeof silence => x === silence;
|
|
7
13
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// The words a door says to a key it has bound, and no other. They ride on
|
|
15
|
+
// the wire as `{ quo: word }`, so a reply is checked against this list.
|
|
16
|
+
export const DOOR_WORDS: readonly DoorWord[] = ['removed', 'absent', 'unannounced', 'repeated', 'threw'];
|
|
17
|
+
export const isDoorWord = (s: unknown): s is DoorWord => typeof s === 'string' && (DOOR_WORDS as readonly string[]).includes(s);
|
|
18
|
+
|
|
19
|
+
const WORDS = new Map<WordName, Word>();
|
|
20
|
+
export const word = <W extends WordName>(name: W): Word<W> => {
|
|
21
|
+
let w = WORDS.get(name);
|
|
22
|
+
if (!w) WORDS.set(name, (w = Object.freeze({ [WORD_KEY]: name })));
|
|
23
|
+
return w as Word<W>;
|
|
24
|
+
};
|
|
25
|
+
export const isWord = (x: unknown): x is Word => x !== null && typeof x === 'object' && typeof (x as Record<symbol, unknown>)[WORD_KEY] === 'string';
|
|
26
|
+
export const wordOf = (x: Word): WordName => x[WORD_KEY];
|
|
27
|
+
|
|
28
|
+
// Unreached: no far door was reached. Nothing is known to have been
|
|
29
|
+
// delivered, so asking again is safe. A being cannot produce it: a ward that
|
|
30
|
+
// sees a word come out of a being reads it as her having thrown.
|
|
31
|
+
export const unreached = (): Unreached => word('unreached');
|
|
32
|
+
export const isUnreached = (x: unknown): x is Unreached => isWord(x) && wordOf(x) === 'unreached';
|
package/src/being/types.ts
CHANGED
|
@@ -44,11 +44,21 @@ export type Cells = {
|
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
export type Silence = typeof silence;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
|
|
48
|
+
// The ward's words: what her ward says when no object came back and it knows
|
|
49
|
+
// why. A door says the first five to a key it has bound, and rides them on
|
|
50
|
+
// the wire as `{ quo: word }`; her own ward says the last four to her and
|
|
51
|
+
// they never leave the ward. Each is one frozen object under one symbol key,
|
|
52
|
+
// so no being makes one and none crosses an edge as a value.
|
|
53
|
+
export type DoorWord = 'removed' | 'absent' | 'unannounced' | 'repeated' | 'threw';
|
|
54
|
+
export type WardWord = 'unreached' | 'late' | 'invitation' | 'dropped';
|
|
55
|
+
export type WordName = DoorWord | WardWord;
|
|
56
|
+
export const WORD_KEY: unique symbol = Symbol.for('quo.word');
|
|
57
|
+
export type Word<W extends WordName = WordName> = { readonly [K in typeof WORD_KEY]: W };
|
|
58
|
+
export type Unreached = Word<'unreached'>;
|
|
49
59
|
|
|
50
60
|
// What comes back from an ask or a knock.
|
|
51
|
-
export type Answer = Json | Silence |
|
|
61
|
+
export type Answer = Json | Silence | Word;
|
|
52
62
|
// What a being answers.
|
|
53
63
|
export type Reply = Json | Silence;
|
|
54
64
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The fixture beings. Fixed, so that the ward is what varies. A printer, a
|
|
3
3
|
// shop, a customer on the base class, and one raw being with no base at all.
|
|
4
4
|
import { Being } from '../being/being.ts';
|
|
5
|
-
import { isSilence, isUnreached } from '../being/silence.ts';
|
|
5
|
+
import { isSilence, isUnreached, isWord } from '../being/silence.ts';
|
|
6
6
|
import type { Asker, Blueprint, Invitation, JsonObject, OccupantRecord, Reply, Stance } from '../being/types.ts';
|
|
7
7
|
|
|
8
8
|
// A printer. Invites whom she is told to, prints for her occupants. Her
|
|
@@ -46,7 +46,7 @@ export class Shop extends Being {
|
|
|
46
46
|
|
|
47
47
|
async keepPrinter(invitation: Invitation): Promise<boolean> {
|
|
48
48
|
const bp = await this.knock(invitation);
|
|
49
|
-
if (isSilence(bp) ||
|
|
49
|
+
if (isSilence(bp) || isWord(bp)) return false;
|
|
50
50
|
this.cells.printerId = await this.take('prn', invitation);
|
|
51
51
|
return true;
|
|
52
52
|
}
|
|
@@ -55,7 +55,7 @@ export class Shop extends Being {
|
|
|
55
55
|
const rec = this.occupant(asker);
|
|
56
56
|
if (rec?.notes.tier === 'gold' && invitation) {
|
|
57
57
|
const back = await this.knock(invitation as Invitation, 'hi');
|
|
58
|
-
if (!isSilence(back) && !
|
|
58
|
+
if (!isSilence(back) && !isWord(back)) await this.take(`vip-${asker.id}`, invitation as Invitation);
|
|
59
59
|
}
|
|
60
60
|
return { welcome: true };
|
|
61
61
|
}
|
|
@@ -67,7 +67,7 @@ export class Shop extends Being {
|
|
|
67
67
|
if (!printer) return { error: 'no printer' };
|
|
68
68
|
const out = await printer.ask('print', { doc: `receipt for ${item}` });
|
|
69
69
|
if (isUnreached(out)) return { error: 'printer unreachable', retry: true };
|
|
70
|
-
if (isSilence(out)) return { error: 'printer refused' };
|
|
70
|
+
if (isSilence(out) || isWord(out)) return { error: 'printer refused' };
|
|
71
71
|
const st = this.cells.standings[id!];
|
|
72
72
|
if (st.seen !== st.digest) this.occupant(asker)!.notes.printerChanged = true;
|
|
73
73
|
(this.cells.sales as JsonObject[]).push({ item: item ?? null, by: asker.id ?? null });
|
|
@@ -88,7 +88,7 @@ export class Customer extends Being {
|
|
|
88
88
|
async join(invitation: Invitation) {
|
|
89
89
|
const mine = (await this.invite('shop-back'))!;
|
|
90
90
|
const out = await this.knock(invitation, 'hello', { invitation: mine as never });
|
|
91
|
-
if (isSilence(out) ||
|
|
91
|
+
if (isSilence(out) || isWord(out)) return out; // nothing was born
|
|
92
92
|
await this.take('shop', invitation); // now, and only now
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
@@ -33,12 +33,13 @@
|
|
|
33
33
|
// A model that kept one symmetric edge would be a wrong model, and would
|
|
34
34
|
// agree happily with a ward that had drifted.
|
|
35
35
|
import { assert } from './assert.ts';
|
|
36
|
-
import { isSilence, isUnreached } from '../being/silence.ts';
|
|
36
|
+
import { isSilence, isUnreached, isWord, wordOf } from '../being/silence.ts';
|
|
37
37
|
import type { Invitation } from '../being/types.ts';
|
|
38
38
|
import { Member } from './beings.ts';
|
|
39
39
|
import type { Census, Handle, Runner, World } from './index.ts';
|
|
40
40
|
|
|
41
41
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
42
|
+
const said = (x: unknown, w: string): boolean => isWord(x) && wordOf(x) === w;
|
|
42
43
|
|
|
43
44
|
// One occupant arc, as the host holds it. `guest` is null until somebody
|
|
44
45
|
// knocks; `live` goes false when she is kicked. Both are the host's business
|
|
@@ -161,10 +162,11 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
|
|
|
161
162
|
assert.deepEqual(await acme.being.knock(back, 'ping'), { pong: 'acme' });
|
|
162
163
|
assert.equal(await acme.being.take('them', back), 'them');
|
|
163
164
|
|
|
164
|
-
// 5. acme kicks her. her standing is still hers to hold
|
|
165
|
-
// told
|
|
165
|
+
// 5. acme kicks her. her standing is still hers to hold, and she is not
|
|
166
|
+
// told until she asks: then the door, under the key it bound for her,
|
|
167
|
+
// says removed, and only to her.
|
|
166
168
|
acme.being.occupants.remove('partner-1');
|
|
167
|
-
assert.ok(
|
|
169
|
+
assert.ok(said(await partner.being.standings['the-client']!.ask('ping'), 'removed'));
|
|
168
170
|
assert.deepEqual(Object.keys(partner.cells.standings), ['the-client']); // she never learns
|
|
169
171
|
assert.deepEqual(await acme.being.standings.them!.ask('ping'), { pong: 'acme' }); // the other arc never moved
|
|
170
172
|
|
|
@@ -174,7 +176,7 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
|
|
|
174
176
|
assert.notEqual(second.heir, first.heir);
|
|
175
177
|
assert.deepEqual(await partner.being.knock(second, 'ping'), { pong: 'partner-2' });
|
|
176
178
|
assert.equal(await partner.being.take('the-client-again', second), 'the-client-again');
|
|
177
|
-
assert.ok(
|
|
179
|
+
assert.ok(said(await partner.being.standings['the-client']!.ask('ping'), 'removed'));
|
|
178
180
|
ledger(w.census(), undefined, 'the story');
|
|
179
181
|
});
|
|
180
182
|
|
|
@@ -221,6 +223,9 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
|
|
|
221
223
|
assert.deepEqual(back, { pong: s.id }, `${guest} knocking ${s.host}/${s.id}`);
|
|
222
224
|
s.guest = guest;
|
|
223
225
|
s.answered = true;
|
|
226
|
+
} else if (!s.live && (s.guest === null || (s.guest === guest && (hold === undefined || hold.live)))) {
|
|
227
|
+
// kicked, and she speaks under a key the door bound for this seat: she hears why
|
|
228
|
+
assert.ok(said(back, 'removed'), `${guest} knocking kicked ${s.host}/${s.id} should hear removed`);
|
|
224
229
|
} else assert.ok(isSilence(back), `${guest} knocking ${s.host}/${s.id} should be silence`);
|
|
225
230
|
return `${guest} knocks ${s.host}/${s.id}`;
|
|
226
231
|
});
|
|
@@ -304,7 +309,7 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
|
|
|
304
309
|
}
|
|
305
310
|
const out = await standing!.ask('ping');
|
|
306
311
|
if (h.seat.live) assert.deepEqual(out, { pong: h.seat.id }, `step ${step}: ${h.owner}/${h.id} -> ${h.seat.host}/${h.seat.id}`);
|
|
307
|
-
else assert.ok(
|
|
312
|
+
else assert.ok(said(out, 'removed'), `step ${step}: ${h.owner}/${h.id} is kicked and should hear removed`);
|
|
308
313
|
}
|
|
309
314
|
};
|
|
310
315
|
|
package/src/conformance/index.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Every assertion here is the truth's. Nothing here knows how a ward is built.
|
|
21
21
|
import { assert } from './assert.ts';
|
|
22
|
-
import { silence, isSilence, isUnreached } from '../being/silence.ts';
|
|
22
|
+
import { silence, isSilence, isUnreached, isWord, wordOf } from '../being/silence.ts';
|
|
23
23
|
import { digest } from '../being/digest.ts';
|
|
24
24
|
import type { Asker, BeingClass, Cells, Invitation, Json, JsonObject } from '../being/types.ts';
|
|
25
25
|
import { Printer, Shop, Customer, Echo, Member } from './beings.ts';
|
|
@@ -54,6 +54,8 @@ export type World = {
|
|
|
54
54
|
export { Printer, Shop, Customer, Echo, Member };
|
|
55
55
|
|
|
56
56
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
57
|
+
// Her ward's word, as she receives it.
|
|
58
|
+
const said = (x: unknown, w: string): boolean => isWord(x) && wordOf(x) === w;
|
|
57
59
|
|
|
58
60
|
// The runner is handed in. Node passes `node:test`; a browser and an edge
|
|
59
61
|
// worker pass a shim of their own. The suite is the ward's truth, and the
|
|
@@ -100,12 +102,12 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
|
|
|
100
102
|
assert.equal(i1.ward, i2.ward);
|
|
101
103
|
});
|
|
102
104
|
|
|
103
|
-
t('remove before any knock: the heir is forgotten, a knock on it
|
|
105
|
+
t('remove before any knock: the heir is forgotten, and a knock on it by whoever holds it hears removed', async () => {
|
|
104
106
|
const { w, shop, alice } = await world();
|
|
105
107
|
const inv = await shop.being.invite();
|
|
106
108
|
shop.being.occupants.remove('cust1');
|
|
107
109
|
assert.equal(w.heir(inv), undefined);
|
|
108
|
-
assert.ok(
|
|
110
|
+
assert.ok(said(await alice.being.join(inv), 'removed'));
|
|
109
111
|
assert.deepEqual(alice.cells.standings, {});
|
|
110
112
|
});
|
|
111
113
|
|
|
@@ -189,16 +191,16 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
|
|
|
189
191
|
assert.deepEqual(await alice.being.join(inv), { welcome: true }); // the same invitation still works
|
|
190
192
|
const inv2 = await shop.being.invite();
|
|
191
193
|
shop.being.occupants.remove('cust2'); // she changed her mind before the knock
|
|
192
|
-
assert.ok(
|
|
194
|
+
assert.ok(said(await alice.being.join(inv2), 'removed'));
|
|
193
195
|
assert.deepEqual(Object.keys(alice.cells.standings), ['shop']);
|
|
194
196
|
});
|
|
195
197
|
|
|
196
|
-
t('take without a knock, or after a
|
|
198
|
+
t('take without a knock, or after a knock that was not answered, births nothing', async () => {
|
|
197
199
|
const { shop, alice } = await world();
|
|
198
200
|
const inv = await shop.being.invite();
|
|
199
201
|
assert.equal(await alice.being.take('shop', inv), null);
|
|
200
202
|
shop.being.occupants.remove('cust1');
|
|
201
|
-
assert.ok(
|
|
203
|
+
assert.ok(said(await alice.being.knock(inv, 'hello'), 'removed'));
|
|
202
204
|
assert.equal(await alice.being.take('shop', inv), null);
|
|
203
205
|
assert.deepEqual(alice.cells.standings, {});
|
|
204
206
|
});
|
|
@@ -374,16 +376,16 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
|
|
|
374
376
|
assert.deepEqual(await alice.being.buy('hat'), { ok: true, receipt: 'receipt for hat' });
|
|
375
377
|
});
|
|
376
378
|
|
|
377
|
-
t('a removed occupant
|
|
379
|
+
t('a removed occupant hears removed under the key the door bound, her heir is forgotten; a dropped standing is gone', async () => {
|
|
378
380
|
const { w, alice, shop } = await world();
|
|
379
381
|
const inv = await shop.being.invite();
|
|
380
382
|
await alice.being.join(inv);
|
|
381
383
|
shop.being.occupants.remove('cust1');
|
|
382
384
|
assert.equal(w.heir(inv), undefined);
|
|
383
|
-
assert.ok(
|
|
385
|
+
assert.ok(said(await alice.being.buy('hat'), 'removed'));
|
|
384
386
|
alice.being.standings.remove('shop');
|
|
385
387
|
assert.equal(alice.being.standings.shop, undefined);
|
|
386
|
-
assert.ok(isSilence(await alice.being.knock(inv, 'hello'))); //
|
|
388
|
+
assert.ok(isSilence(await alice.being.knock(inv, 'hello'))); // her bound key went with the standing: a re-knock is as the heir, which is spent, a stranger
|
|
387
389
|
const again = await shop.being.invite(); // cust2. a new heir
|
|
388
390
|
assert.notEqual(again.heir, inv.heir);
|
|
389
391
|
assert.deepEqual(await alice.being.knock(again, 'hello'), { welcome: true });
|
|
@@ -398,7 +400,7 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
|
|
|
398
400
|
assert.equal((shop.cells.sales as unknown[]).length, 1);
|
|
399
401
|
});
|
|
400
402
|
|
|
401
|
-
t('a being that throws is
|
|
403
|
+
t('a being that throws is the word threw to whom the door has bound; the ward stays up', async () => {
|
|
402
404
|
const w = await make();
|
|
403
405
|
class Bomb {
|
|
404
406
|
s: unknown;
|
|
@@ -413,7 +415,7 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
|
|
|
413
415
|
const x = await w.boot<Bomb>('X', Bomb);
|
|
414
416
|
const a = await w.boot<Customer>('A', Customer);
|
|
415
417
|
const inv = (await (x.being.s as Customer['stance']).occupants.invite('a'))!;
|
|
416
|
-
assert.ok(
|
|
418
|
+
assert.ok(said(await a.being.knock(inv, 'any'), 'threw'));
|
|
417
419
|
assert.deepEqual(await a.being.knock(inv), { asks: [], notes: {} });
|
|
418
420
|
});
|
|
419
421
|
|
|
@@ -473,7 +475,7 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
|
|
|
473
475
|
assert.deepEqual(pub.cells.occupants, {}); // she never invited anyone
|
|
474
476
|
if (!w.oneWard) assert.ok(isSilence(await a.being.knock(at(priv), 'time'))); // a ward with no public being: silence
|
|
475
477
|
assert.ok(isUnreached(await a.being.knock({ ward: 'a'.repeat(128) }, 'time'))); // no door anywhere: unreached
|
|
476
|
-
assert.ok(
|
|
478
|
+
assert.ok(said(await a.being.knock({ ward: 'not a pk' }, 'time'), 'invitation')); // not even an address
|
|
477
479
|
assert.equal(await a.being.take('clock', at(pub)), 'clock');
|
|
478
480
|
assert.deepEqual(await a.being.standings.clock!.ask('time'), { time: 12, got: {} });
|
|
479
481
|
assert.equal(a.cells.standings.clock.seen, await digest((await a.being.standings.clock!.ask()) as Json));
|
package/src/harbor/core.ts
CHANGED
|
@@ -45,6 +45,7 @@ export class Harbor {
|
|
|
45
45
|
readonly doors = new Map<string, WardPointers['door']>(); // ward pk -> door
|
|
46
46
|
readonly reaches = new Map<string, Bound>(); // the directory: foreign ward pk -> reach
|
|
47
47
|
readonly down = new Set<string>(); // pks this harbor will not reach right now: weather, for a test
|
|
48
|
+
readonly refused = new Map<string, number>(); // own ward pk -> arrivals its door would not admit. what a terrain does with it is its own
|
|
48
49
|
readonly classes: Record<string, BeingClass> = {}; // classes handed in-process, beside the loader's
|
|
49
50
|
// The dialers' sockets, in the order they were dialed: where a pk nobody
|
|
50
51
|
// here knows is sent, because the listener a harbor dialed is a rendezvous
|
|
@@ -71,10 +72,18 @@ export class Harbor {
|
|
|
71
72
|
|
|
72
73
|
// ---- the directory
|
|
73
74
|
|
|
75
|
+
// Bytes to one of this harbor's own doors, and the one bit the door says
|
|
76
|
+
// beside its reply, counted.
|
|
77
|
+
async #own(pk: string, door: WardPointers['door'], bytes: Uint8Array): Promise<Uint8Array> {
|
|
78
|
+
const r = await door(bytes);
|
|
79
|
+
if (!r.heard) this.refused.set(pk, (this.refused.get(pk) ?? 0) + 1);
|
|
80
|
+
return new Uint8Array(r.bytes);
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
async carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
|
|
75
84
|
if (this.down.has(pk)) return undefined;
|
|
76
85
|
const door = this.doors.get(pk);
|
|
77
|
-
if (door) return
|
|
86
|
+
if (door) return this.#own(pk, door, bytes);
|
|
78
87
|
const bound = this.reaches.get(pk);
|
|
79
88
|
if (bound) return bound.reach.carry(pk, bytes);
|
|
80
89
|
for (const reach of this.fallbacks) {
|
|
@@ -87,7 +96,7 @@ export class Harbor {
|
|
|
87
96
|
async deliver(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
|
|
88
97
|
if (this.down.has(pk)) return undefined;
|
|
89
98
|
const door = this.doors.get(pk);
|
|
90
|
-
if (door) return
|
|
99
|
+
if (door) return this.#own(pk, door, bytes);
|
|
91
100
|
const bound = this.reaches.get(pk);
|
|
92
101
|
return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
|
|
93
102
|
}
|
package/src/harbor/memory.ts
CHANGED
|
@@ -15,6 +15,7 @@ export class MemoryHarbor {
|
|
|
15
15
|
readonly doors = new Map<string, WardPointers['door']>(); // ward pk -> door
|
|
16
16
|
readonly down = new Set<string>(); // ward pks this harbor cannot reach right now
|
|
17
17
|
readonly wire: string[] = []; // every byte string that ever crossed, as hex. for inspection
|
|
18
|
+
readonly refused = new Map<string, number>(); // ward pk -> arrivals its door would not admit: strangers, counted
|
|
18
19
|
readonly peers = new Set<MemoryHarbor>();
|
|
19
20
|
cut = false;
|
|
20
21
|
readonly partitions = new Map<string, Record<string, unknown>>(); // seed -> memory. the harbor keeps it and reads nothing
|
|
@@ -25,7 +26,11 @@ export class MemoryHarbor {
|
|
|
25
26
|
async route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
|
|
26
27
|
if (this.down.has(farPk)) return undefined;
|
|
27
28
|
const door = this.doors.get(farPk);
|
|
28
|
-
if (door)
|
|
29
|
+
if (door) {
|
|
30
|
+
const r = await door(bytes);
|
|
31
|
+
if (!r.heard) this.refused.set(farPk, (this.refused.get(farPk) ?? 0) + 1);
|
|
32
|
+
return r.bytes;
|
|
33
|
+
}
|
|
29
34
|
const peer = [...this.peers].find((h) => h.doors.has(farPk) && !h.down.has(farPk));
|
|
30
35
|
if (!peer || this.cut) return undefined; // nothing. the harbor has no word silence
|
|
31
36
|
return peer.route(farPk, bytes);
|