babavoss 0.0.1 → 0.0.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/NOTICE +7 -0
- package/bin/voss.ts +48 -0
- package/gui/babavoss-web.js +234 -0
- package/gui/chunk-5hpp1ypv.js +11710 -0
- package/gui/chunk-9rq662fd.js +8627 -0
- package/gui/chunk-k3cm8j6r.js +433 -0
- package/gui/chunk-pk09y98y.js +50 -0
- package/gui/chunk-smz02qa6.js +186 -0
- package/gui/chunk-wwqypxre.js +49 -0
- package/gui/gui.js +2015 -0
- package/gui/react-compiler-runtime.js +39 -0
- package/gui/react-dom-client.js +21 -0
- package/gui/react-dom.js +44 -0
- package/gui/react-jsx-runtime.js +19 -0
- package/gui/react.js +105 -0
- package/gui/theme.css +3053 -0
- package/index.ts +15 -0
- package/package.json +50 -4
- package/src/baba/check.ts +90 -0
- package/src/baba/config.ts +261 -0
- package/src/baba/find.ts +12 -0
- package/src/baba/init.ts +176 -0
- package/src/baba/node.ts +483 -0
- package/src/baba/project.ts +63 -0
- package/src/baba/registry.ts +35 -0
- package/src/baba/worker.ts +55 -0
- package/src/bench/index.ts +6 -0
- package/src/bench/measure.ts +132 -0
- package/src/bench/scenarios.ts +136 -0
- package/src/build/builder.ts +74 -0
- package/src/build/failure.ts +78 -0
- package/src/build/guard.ts +85 -0
- package/src/build/mdx-register.ts +3 -0
- package/src/build/mdx.ts +38 -0
- package/src/build/project.ts +38 -0
- package/src/build/views.ts +146 -0
- package/src/builder/main.ts +29 -0
- package/src/desktop/bob.ts +76 -0
- package/src/desktop/desktop.css +111 -0
- package/src/desktop/icons.ts +50 -0
- package/src/desktop/index.ts +323 -0
- package/src/desktop/routes.ts +98 -0
- package/src/desktop/view.tsx +673 -0
- package/src/door/core.ts +384 -0
- package/src/ecs/baba.ts +431 -0
- package/src/ecs/codec.ts +334 -0
- package/src/ecs/handles.ts +91 -0
- package/src/ecs/replica.ts +150 -0
- package/src/ecs/runtime.ts +603 -0
- package/src/ecs/scheduler.ts +75 -0
- package/src/ecs/snapshot.ts +102 -0
- package/src/ecs/state.ts +759 -0
- package/src/ecs/system.ts +256 -0
- package/src/ecs/table.ts +420 -0
- package/src/ecs/testbed.ts +97 -0
- package/src/exec/host.ts +177 -0
- package/src/exec/main.ts +98 -0
- package/src/exec/watch.ts +7 -0
- package/src/exec/wire.ts +29 -0
- package/src/generated/build.ts +4 -0
- package/src/gui/css.d.ts +1 -0
- package/src/gui/gui.tsx +245 -0
- package/src/gui/index.ts +51 -0
- package/src/gui/inspector.tsx +47 -0
- package/src/gui/levels.tsx +73 -0
- package/src/gui/promptware.tsx +68 -0
- package/src/gui/runner.tsx +118 -0
- package/src/gui/theme.css +498 -0
- package/src/gui/theme.ts +25 -0
- package/src/gui/wizard.tsx +227 -0
- package/src/guide/add-a-desktop.mdx +100 -0
- package/src/guide/compose-an-interface.mdx +84 -0
- package/src/guide/index.ts +13 -0
- package/src/guide/reach-outside.mdx +112 -0
- package/src/guide/spec-a-system.mdx +93 -0
- package/src/guide/systems-together.mdx +72 -0
- package/src/guide/write-a-system.mdx +183 -0
- package/src/guide/write-promptware.mdx +90 -0
- package/src/http/server.ts +310 -0
- package/src/kernel/build.ts +21 -0
- package/src/kernel/builder.ts +105 -0
- package/src/kernel/children.ts +117 -0
- package/src/kernel/context.ts +90 -0
- package/src/kernel/lock.ts +46 -0
- package/src/kernel/names.ts +14 -0
- package/src/kernel/schema.ts +130 -0
- package/src/kernel/where.ts +12 -0
- package/src/kit/index.ts +232 -0
- package/src/maker/system.ts +213 -0
- package/src/mcp/daemon.ts +61 -0
- package/src/mcp/main.ts +208 -0
- package/src/mcp/rpc.ts +64 -0
- package/src/mcp/tools.ts +125 -0
- package/src/prompt/evals.ts +42 -0
- package/src/prompt/index.ts +242 -0
- package/src/prompt/jsx-dev-runtime.ts +1 -0
- package/src/prompt/jsx-runtime.ts +49 -0
- package/src/prompt/mdx.d.ts +1 -0
- package/src/promptware/compile.ts +183 -0
- package/src/promptware/define.ts +16 -0
- package/src/promptware/disk.ts +72 -0
- package/src/promptware/markdown.d.ts +6 -0
- package/src/promptware/sync.ts +437 -0
- package/src/promptware/system.ts +215 -0
- package/src/runtime/bridge.ts +85 -0
- package/src/runtime/connect.ts +54 -0
- package/src/runtime/env.ts +35 -0
- package/src/runtime/harness.ts +80 -0
- package/src/runtime/main.ts +119 -0
- package/src/runtime/worker.ts +33 -0
- package/src/server/edge.ts +332 -0
- package/src/server/main.ts +45 -0
- package/src/server/messages.ts +97 -0
- package/src/server/protocol.ts +37 -0
- package/src/services/args.ts +45 -0
- package/src/services/exec.ts +69 -0
- package/src/services/fs.ts +139 -0
- package/src/services/http.ts +30 -0
- package/src/services/index.ts +113 -0
- package/src/services/secrets.ts +18 -0
- package/src/shell/address.ts +21 -0
- package/src/shell/args.ts +219 -0
- package/src/shell/client.ts +107 -0
- package/src/shell/codes.ts +26 -0
- package/src/shell/positional.ts +20 -0
- package/src/shell/run.ts +470 -0
- package/src/shell/service.ts +167 -0
- package/src/shell/state.ts +204 -0
- package/src/spec/adapters.ts +72 -0
- package/src/spec/diff.ts +26 -0
- package/src/spec/files.ts +17 -0
- package/src/spec/index.ts +155 -0
- package/src/spec/run.ts +97 -0
- package/src/spec/take.ts +54 -0
- package/src/test/index.ts +8 -0
- package/src/test/prove.ts +56 -0
- package/src/test/records.ts +23 -0
- package/src/test/specs.ts +56 -0
- package/src/test/steps.ts +100 -0
- package/src/test/voss-dir.ts +17 -0
- package/src/transport/messages.ts +110 -0
- package/src/transport/transport.ts +62 -0
- package/src/wall/probe.ts +67 -0
- package/src/wall/profile.ts +103 -0
- package/src/wall/spawn.ts +59 -0
- package/src/web/app.tsx +53 -0
- package/src/web/core.tsx +140 -0
- package/src/web/form.ts +155 -0
- package/src/web/hooks.ts +135 -0
- package/src/web/index.tsx +17 -0
- package/src/web/list.ts +19 -0
- package/src/web/maker.tsx +766 -0
- package/src/web/objects.tsx +213 -0
- package/src/web/socket.ts +84 -0
- package/src/web/state.tsx +69 -0
- package/src/web/store.ts +221 -0
- package/src/web/ui.tsx +135 -0
- package/README.md +0 -5
package/src/ecs/codec.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// A frame: a round's state as bytes, for the wire between the runtime and a
|
|
2
|
+
// window. Values are laid out by their schema, which both ends hold, so a
|
|
3
|
+
// number is eight bytes, an integer a varint, a tag nothing; what the schema
|
|
4
|
+
// cannot type travels as JSON text inside. Component and resource ids are
|
|
5
|
+
// their positions in the model, the same order the manifest lists them in.
|
|
6
|
+
// Pure, browser-safe.
|
|
7
|
+
import type { Json } from "../kernel/schema.ts";
|
|
8
|
+
import type { Snapshot } from "./state.ts";
|
|
9
|
+
import { isDelta, PRESENT, type Delta, type StateAnswer } from "./snapshot.ts";
|
|
10
|
+
import type { Interest, Declared } from "../server/protocol.ts";
|
|
11
|
+
export type { Declared };
|
|
12
|
+
|
|
13
|
+
/** The table both ends agree on: names in id order, with the schema each value is laid out by. */
|
|
14
|
+
export interface Codec {
|
|
15
|
+
components: Declared[];
|
|
16
|
+
resources: Declared[];
|
|
17
|
+
componentId: Map<string, number>;
|
|
18
|
+
resourceId: Map<string, number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function codecOf(components: Declared[], resources: Declared[]): Codec {
|
|
22
|
+
return { components, resources, componentId: new Map(components.map((c, i) => [c.name, i])), resourceId: new Map(resources.map((r, i) => [r.name, i])) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const FRAME_VERSION = 2;
|
|
26
|
+
const MAGIC = 0x42;
|
|
27
|
+
const WHOLE = 1, DELTA = 2;
|
|
28
|
+
/** A part's tag: how its value follows. TYPED and TEXT are followed by the payload's byte length, so a reader can skip without a schema. The NEW bit says the part arrived since: what a follower of presence alone takes. */
|
|
29
|
+
const REMOVED = 0, TYPED = 1, TEXT = 2, HELD = 3;
|
|
30
|
+
const NEW = 8, KIND = 7;
|
|
31
|
+
/** The blocks a state carries beside its entities and resources, as JSON text, each when present. */
|
|
32
|
+
const BLOCKS = ["jobs", "next", "settled", "keys", "made"] as const;
|
|
33
|
+
|
|
34
|
+
const utf8 = new TextEncoder();
|
|
35
|
+
const utf8d = new TextDecoder();
|
|
36
|
+
|
|
37
|
+
class Writer {
|
|
38
|
+
private buf = new Uint8Array(1024);
|
|
39
|
+
private view = new DataView(this.buf.buffer);
|
|
40
|
+
at = 0;
|
|
41
|
+
private room(n: number): void {
|
|
42
|
+
if (this.at + n <= this.buf.length) return;
|
|
43
|
+
let size = this.buf.length * 2;
|
|
44
|
+
while (size < this.at + n) size *= 2;
|
|
45
|
+
const next = new Uint8Array(size);
|
|
46
|
+
next.set(this.buf);
|
|
47
|
+
this.buf = next; this.view = new DataView(next.buffer);
|
|
48
|
+
}
|
|
49
|
+
u8(n: number): void { this.room(1); this.buf[this.at++] = n; }
|
|
50
|
+
/** An unsigned integer up to 2^53, seven bits at a time. */
|
|
51
|
+
varint(n: number): void { while (n >= 128) { this.u8((n % 128) | 128); n = Math.floor(n / 128); } this.u8(n); }
|
|
52
|
+
/** A signed integer, zigzagged. */
|
|
53
|
+
int(n: number): void { this.varint(n >= 0 ? n * 2 : -n * 2 - 1); }
|
|
54
|
+
f64(n: number): void { this.room(8); this.view.setFloat64(this.at, n, true); this.at += 8; }
|
|
55
|
+
str(s: string): void { const b = utf8.encode(s); this.varint(b.length); this.raw(b); }
|
|
56
|
+
bytes(): Uint8Array { return this.buf.slice(0, this.at); }
|
|
57
|
+
/** Writes what `fn` writes behind its byte length. */
|
|
58
|
+
withLength(fn: () => void): void {
|
|
59
|
+
this.room(5);
|
|
60
|
+
const mark = this.at;
|
|
61
|
+
this.at += 5;
|
|
62
|
+
const start = this.at;
|
|
63
|
+
fn();
|
|
64
|
+
const len = this.at - start;
|
|
65
|
+
let k = 1; for (let n = len; n >= 128; n = Math.floor(n / 128)) k++;
|
|
66
|
+
this.buf.copyWithin(mark + k, start, this.at);
|
|
67
|
+
this.at = mark;
|
|
68
|
+
this.varint(len);
|
|
69
|
+
this.at = mark + k + len;
|
|
70
|
+
}
|
|
71
|
+
raw(bytes: Uint8Array): void { this.room(bytes.length); this.buf.set(bytes, this.at); this.at += bytes.length; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Reader {
|
|
75
|
+
private view: DataView;
|
|
76
|
+
at = 0;
|
|
77
|
+
constructor(private buf: Uint8Array) { this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); }
|
|
78
|
+
u8(): number { if (this.at >= this.buf.length) throw new Error("frame: cut short"); return this.buf[this.at++]!; }
|
|
79
|
+
varint(): number { let n = 0, mul = 1; for (;;) { const b = this.u8(); n += (b & 127) * mul; if (b < 128) return n; mul *= 128; } }
|
|
80
|
+
int(): number { const z = this.varint(); return z % 2 === 0 ? z / 2 : -(z + 1) / 2; }
|
|
81
|
+
f64(): number { if (this.at + 8 > this.buf.length) throw new Error("frame: cut short"); const n = this.view.getFloat64(this.at, true); this.at += 8; return n; }
|
|
82
|
+
str(): string { const len = this.varint(); if (this.at + len > this.buf.length) throw new Error("frame: cut short"); const s = utf8d.decode(this.buf.subarray(this.at, this.at + len)); this.at += len; return s; }
|
|
83
|
+
/** The next `len` bytes, as a view. */
|
|
84
|
+
take(len: number): Uint8Array { if (this.at + len > this.buf.length) throw new Error("frame: cut short"); const v = this.buf.subarray(this.at, this.at + len); this.at += len; return v; }
|
|
85
|
+
done(): boolean { return this.at >= this.buf.length; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class Untyped extends Error {}
|
|
89
|
+
|
|
90
|
+
/** Writes `v` as `json` lays it out; throws Untyped where the value is not what the schema says, so the caller falls back to text. */
|
|
91
|
+
function writeValue(w: Writer, j: Json, v: unknown): void {
|
|
92
|
+
if (j.const !== undefined) { if (v !== j.const) throw new Untyped(); return; }
|
|
93
|
+
if (Array.isArray(j.type)) {
|
|
94
|
+
if (v === null) { w.u8(0); return; }
|
|
95
|
+
w.u8(1);
|
|
96
|
+
writeValue(w, { ...j, type: j.type.find((t) => t !== "null") }, v);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
switch (j.type) {
|
|
100
|
+
case "string": if (typeof v !== "string") throw new Untyped(); w.str(v); return;
|
|
101
|
+
case "number": if (typeof v !== "number" || !Number.isFinite(v)) throw new Untyped(); w.f64(v); return;
|
|
102
|
+
case "integer": if (typeof v !== "number" || !Number.isSafeInteger(v)) throw new Untyped(); w.int(v); return;
|
|
103
|
+
case "boolean": if (typeof v !== "boolean") throw new Untyped(); w.u8(v ? 1 : 0); return;
|
|
104
|
+
case "null": if (v !== null) throw new Untyped(); return;
|
|
105
|
+
case "array": {
|
|
106
|
+
if (!Array.isArray(v) || typeof j.items !== "object" || j.items === null) throw new Untyped();
|
|
107
|
+
w.varint(v.length);
|
|
108
|
+
for (const x of v) writeValue(w, j.items as Json, x);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case "object": {
|
|
112
|
+
const props = j.properties as Record<string, Json> | undefined;
|
|
113
|
+
if (!props || typeof v !== "object" || v === null || Array.isArray(v)) throw new Untyped();
|
|
114
|
+
const o = v as Record<string, unknown>;
|
|
115
|
+
for (const k in o) if (!(k in props)) throw new Untyped();
|
|
116
|
+
const required = (j.required as string[] | undefined) ?? [];
|
|
117
|
+
for (const [k, p] of Object.entries(props)) {
|
|
118
|
+
const x = o[k];
|
|
119
|
+
if (!required.includes(k)) { if (x === undefined) { w.u8(0); continue; } w.u8(1); }
|
|
120
|
+
else if (x === undefined) throw new Untyped();
|
|
121
|
+
writeValue(w, p, x);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
default: throw new Untyped();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readValue(r: Reader, j: Json): unknown {
|
|
130
|
+
if (j.const !== undefined) return j.const;
|
|
131
|
+
if (Array.isArray(j.type)) return r.u8() === 0 ? null : readValue(r, { ...j, type: j.type.find((t) => t !== "null") });
|
|
132
|
+
switch (j.type) {
|
|
133
|
+
case "string": return r.str();
|
|
134
|
+
case "number": return r.f64();
|
|
135
|
+
case "integer": return r.int();
|
|
136
|
+
case "boolean": return r.u8() === 1;
|
|
137
|
+
case "null": return null;
|
|
138
|
+
case "array": { const n = r.varint(); const out = new Array(n); for (let i = 0; i < n; i++) out[i] = readValue(r, j.items as Json); return out; }
|
|
139
|
+
case "object": {
|
|
140
|
+
const props = j.properties as Record<string, Json>;
|
|
141
|
+
const required = (j.required as string[] | undefined) ?? [];
|
|
142
|
+
const out: Record<string, unknown> = {};
|
|
143
|
+
for (const [k, p] of Object.entries(props)) {
|
|
144
|
+
if (!required.includes(k) && r.u8() === 0) continue;
|
|
145
|
+
out[k] = readValue(r, p);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
default: throw new Error(`frame: a value of an untyped schema was laid out as typed`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A value typed by its schema when it fits, else as text; one byte says which, and the payload's length follows it. */
|
|
154
|
+
function writeTagged(w: Writer, j: Json, v: unknown, fresh: boolean): void {
|
|
155
|
+
const mark = w.at;
|
|
156
|
+
const bit = fresh ? NEW : 0;
|
|
157
|
+
w.u8(TYPED | bit);
|
|
158
|
+
try { w.withLength(() => writeValue(w, j, v)); }
|
|
159
|
+
catch (err) { if (!(err instanceof Untyped)) throw err; w.at = mark; w.u8(TEXT | bit); w.withLength(() => w.str(JSON.stringify(v))); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readTagged(r: Reader, j: Json, tag: number): unknown {
|
|
163
|
+
const kind = tag & KIND;
|
|
164
|
+
if (kind === HELD) return PRESENT;
|
|
165
|
+
const len = r.varint();
|
|
166
|
+
if (kind === TEXT) return JSON.parse(r.str());
|
|
167
|
+
if (kind === TYPED) { const end = r.at + len; const v = readValue(r, j); r.at = end; return v; }
|
|
168
|
+
throw new Error(`frame: a value tagged ${tag}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The state as bytes. A component or resource the codec does not know is refused: the codec is the model's. */
|
|
172
|
+
export function encodeFrame(c: Codec, s: StateAnswer): Uint8Array {
|
|
173
|
+
const w = new Writer();
|
|
174
|
+
const delta = isDelta(s);
|
|
175
|
+
w.u8(MAGIC); w.u8(FRAME_VERSION); w.u8(delta ? DELTA : WHOLE);
|
|
176
|
+
w.varint(s.round); w.varint(delta ? s.since : 0);
|
|
177
|
+
if (s.now === undefined) w.u8(0); else { w.u8(1); w.f64(s.now); }
|
|
178
|
+
const ids = Object.keys(s.entities);
|
|
179
|
+
const added = delta ? s.added : undefined;
|
|
180
|
+
w.varint(ids.length);
|
|
181
|
+
for (const id of ids) {
|
|
182
|
+
const parts = s.entities[id]!;
|
|
183
|
+
w.varint(Number(id));
|
|
184
|
+
if (parts === null) { w.u8(0); continue; }
|
|
185
|
+
w.u8(1);
|
|
186
|
+
const names = Object.keys(parts);
|
|
187
|
+
const fresh = added?.[id];
|
|
188
|
+
w.varint(names.length);
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const cid = c.componentId.get(name);
|
|
191
|
+
if (cid === undefined) throw new Error(`frame: no component ${name} in the codec`);
|
|
192
|
+
w.varint(cid);
|
|
193
|
+
const v = parts[name];
|
|
194
|
+
// In a whole every part is new to the follower; in a delta the ones that arrived since.
|
|
195
|
+
const isNew = !delta || fresh?.includes(name) === true;
|
|
196
|
+
if (v === null) w.u8(REMOVED);
|
|
197
|
+
else if (v === PRESENT) w.u8(HELD | (isNew ? NEW : 0));
|
|
198
|
+
else writeTagged(w, c.components[cid]!.json, v, isNew);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const rnames = Object.keys(s.resources);
|
|
202
|
+
w.varint(rnames.length);
|
|
203
|
+
for (const name of rnames) {
|
|
204
|
+
const rid = c.resourceId.get(name);
|
|
205
|
+
if (rid === undefined) throw new Error(`frame: no resource ${name} in the codec`);
|
|
206
|
+
w.varint(rid);
|
|
207
|
+
writeTagged(w, c.resources[rid]!.json, s.resources[name], false);
|
|
208
|
+
}
|
|
209
|
+
const blocks = BLOCKS.filter((b) => s[b] !== undefined);
|
|
210
|
+
w.varint(blocks.length);
|
|
211
|
+
for (const b of blocks) { w.str(b); w.str(JSON.stringify(s[b])); }
|
|
212
|
+
return w.bytes();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The first words of a frame, without reading the rest: whole or delta, the round, since. */
|
|
216
|
+
export function frameHeader(bytes: Uint8Array): { whole: boolean; round: number; since: number } {
|
|
217
|
+
const r = new Reader(bytes);
|
|
218
|
+
if (r.u8() !== MAGIC) throw new Error("frame: not a frame");
|
|
219
|
+
r.u8();
|
|
220
|
+
const whole = r.u8() === WHOLE;
|
|
221
|
+
return { whole, round: r.varint(), since: r.varint() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A window's interest with the names resolved to the frame's ids: what the edge cuts frames by. */
|
|
225
|
+
export interface Resolved { all: boolean; presence: Set<number>; values: Set<number>; parts: Map<number, Set<number>>; resources: Set<number> }
|
|
226
|
+
|
|
227
|
+
export function resolveInterest(i: Interest, cid: Map<string, number>, rid: Map<string, number>): Resolved {
|
|
228
|
+
const ids = (names: string[] | undefined, m: Map<string, number>) => new Set((names ?? []).map((n) => m.get(n)).filter((x): x is number => x !== undefined));
|
|
229
|
+
const parts = new Map<number, Set<number>>();
|
|
230
|
+
for (const [name, es] of Object.entries(i.parts ?? {})) { const c = cid.get(name); if (c === undefined) continue; for (const e of es) { let s = parts.get(e); if (!s) { s = new Set(); parts.set(e, s); } s.add(c); } }
|
|
231
|
+
return { all: i.all === true, presence: ids(i.presence, cid), values: ids(i.values, cid), parts, resources: ids(i.resources, rid) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The frame cut to an interest, without a schema: a part is kept whole when
|
|
236
|
+
* its value is wanted, kept as held when only its presence is and it is new,
|
|
237
|
+
* dropped otherwise; an entity left with nothing goes unless it is gone,
|
|
238
|
+
* and a gone entity goes to every window, an id each; the blocks go to no
|
|
239
|
+
* window but one that wants everything, which is not cut. A delta's `since`
|
|
240
|
+
* is rewritten to `since`, the round the window holds, since frames it was
|
|
241
|
+
* not sent leave it there. Null when a delta has nothing left to say.
|
|
242
|
+
*/
|
|
243
|
+
export function sliceFrame(bytes: Uint8Array, want: Resolved, since: number): Uint8Array | null {
|
|
244
|
+
const r = new Reader(bytes);
|
|
245
|
+
if (r.u8() !== MAGIC) throw new Error("frame: not a frame");
|
|
246
|
+
if (r.u8() !== FRAME_VERSION) throw new Error("frame: another version");
|
|
247
|
+
const kind = r.u8();
|
|
248
|
+
const round = r.varint(); r.varint();
|
|
249
|
+
const now = r.u8() === 1 ? r.f64() : undefined;
|
|
250
|
+
const w = new Writer();
|
|
251
|
+
w.u8(MAGIC); w.u8(FRAME_VERSION); w.u8(kind);
|
|
252
|
+
w.varint(round); w.varint(kind === DELTA ? since : 0);
|
|
253
|
+
if (now === undefined) w.u8(0); else { w.u8(1); w.f64(now); }
|
|
254
|
+
const body = new Writer();
|
|
255
|
+
let entities = 0;
|
|
256
|
+
const n = r.varint();
|
|
257
|
+
for (let i = 0; i < n; i++) {
|
|
258
|
+
const e = r.varint();
|
|
259
|
+
if (r.u8() === 0) { body.varint(e); body.u8(0); entities++; continue; }
|
|
260
|
+
const m = r.varint();
|
|
261
|
+
const kept = new Writer();
|
|
262
|
+
let parts = 0;
|
|
263
|
+
const mine = want.parts.get(e);
|
|
264
|
+
for (let k = 0; k < m; k++) {
|
|
265
|
+
const cid = r.varint();
|
|
266
|
+
const tag = r.u8();
|
|
267
|
+
const t = tag & KIND;
|
|
268
|
+
const payload = t === TYPED || t === TEXT ? r.take(r.varint()) : null;
|
|
269
|
+
const value = want.values.has(cid) || mine?.has(cid) === true;
|
|
270
|
+
const presence = value || want.presence.has(cid);
|
|
271
|
+
if (!presence) continue;
|
|
272
|
+
if (t === REMOVED || t === HELD) { kept.varint(cid); kept.u8(tag); parts++; continue; }
|
|
273
|
+
if (value) { kept.varint(cid); kept.u8(tag); kept.withLength(() => kept.raw(payload!)); parts++; continue; }
|
|
274
|
+
if (tag & NEW) { kept.varint(cid); kept.u8(HELD | NEW); parts++; }
|
|
275
|
+
}
|
|
276
|
+
if (parts === 0) continue;
|
|
277
|
+
body.varint(e); body.u8(1); body.varint(parts); body.raw(kept.bytes()); entities++;
|
|
278
|
+
}
|
|
279
|
+
w.varint(entities); w.raw(body.bytes());
|
|
280
|
+
const rbody = new Writer();
|
|
281
|
+
let resources = 0;
|
|
282
|
+
const rn = r.varint();
|
|
283
|
+
for (let i = 0; i < rn; i++) {
|
|
284
|
+
const rid = r.varint();
|
|
285
|
+
const tag = r.u8();
|
|
286
|
+
const payload = r.take(r.varint());
|
|
287
|
+
if (!want.resources.has(rid)) continue;
|
|
288
|
+
rbody.varint(rid); rbody.u8(tag); rbody.withLength(() => rbody.raw(payload)); resources++;
|
|
289
|
+
}
|
|
290
|
+
w.varint(resources); w.raw(rbody.bytes());
|
|
291
|
+
w.varint(0);
|
|
292
|
+
if (kind === DELTA && entities === 0 && resources === 0) return null;
|
|
293
|
+
return w.bytes();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The state a frame carries, in the shape the replica and the doors apply. */
|
|
297
|
+
export function decodeFrame(c: Codec, bytes: Uint8Array): StateAnswer {
|
|
298
|
+
const r = new Reader(bytes);
|
|
299
|
+
if (r.u8() !== MAGIC) throw new Error("frame: not a frame");
|
|
300
|
+
const version = r.u8();
|
|
301
|
+
if (version !== FRAME_VERSION) throw new Error(`frame: version ${version}, this end reads ${FRAME_VERSION}`);
|
|
302
|
+
const kind = r.u8();
|
|
303
|
+
const round = r.varint(), since = r.varint();
|
|
304
|
+
const now = r.u8() === 1 ? r.f64() : undefined;
|
|
305
|
+
const entities: Record<string, Record<string, unknown> | null> = {};
|
|
306
|
+
const n = r.varint();
|
|
307
|
+
for (let i = 0; i < n; i++) {
|
|
308
|
+
const id = String(r.varint());
|
|
309
|
+
if (r.u8() === 0) { entities[id] = null; continue; }
|
|
310
|
+
const parts: Record<string, unknown> = {};
|
|
311
|
+
const m = r.varint();
|
|
312
|
+
for (let k = 0; k < m; k++) {
|
|
313
|
+
const cid = r.varint();
|
|
314
|
+
const d = c.components[cid];
|
|
315
|
+
if (!d) throw new Error(`frame: component ${cid} is not in the codec`);
|
|
316
|
+
const tag = r.u8();
|
|
317
|
+
parts[d.name] = (tag & KIND) === REMOVED ? null : readTagged(r, d.json, tag);
|
|
318
|
+
}
|
|
319
|
+
entities[id] = parts;
|
|
320
|
+
}
|
|
321
|
+
const resources: Record<string, unknown> = {};
|
|
322
|
+
const rn = r.varint();
|
|
323
|
+
for (let i = 0; i < rn; i++) {
|
|
324
|
+
const rid = r.varint();
|
|
325
|
+
const d = c.resources[rid];
|
|
326
|
+
if (!d) throw new Error(`frame: resource ${rid} is not in the codec`);
|
|
327
|
+
resources[d.name] = readTagged(r, d.json, r.u8());
|
|
328
|
+
}
|
|
329
|
+
const blocks: Record<string, unknown> = {};
|
|
330
|
+
const bn = r.varint();
|
|
331
|
+
for (let i = 0; i < bn; i++) { const name = r.str(); blocks[name] = JSON.parse(r.str()); }
|
|
332
|
+
if (kind === DELTA) return { kind: "delta", round, since, ...(now !== undefined ? { now } : {}), entities, resources, ...blocks } as Delta;
|
|
333
|
+
return { round, ...(now !== undefined ? { now } : {}), entities: entities as Snapshot["entities"], resources, jobs: [], ...blocks } as Snapshot;
|
|
334
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// What a schema's JSON says about a value's fields beyond their type: which
|
|
2
|
+
// field is a component's key (`x-key`), which names an entity carrying a
|
|
3
|
+
// component (`x-entity`), which is a secret (`format: "secret"`). Pure: the
|
|
4
|
+
// walkers read the JSON only, so the runtime, the shell and a window share them.
|
|
5
|
+
import type { Json } from "../kernel/schema.ts";
|
|
6
|
+
|
|
7
|
+
/** What redaction puts in place of a secret. */
|
|
8
|
+
export const REDACTED = "•••";
|
|
9
|
+
|
|
10
|
+
type Leaf = (json: Json, value: unknown, path: string) => unknown;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rebuilds `value` along `json`: at every field whose JSON `match` admits,
|
|
14
|
+
* the value is what `leaf` answers; objects and arrays are walked through
|
|
15
|
+
* their properties and items. Undefined and null are left as they are.
|
|
16
|
+
*/
|
|
17
|
+
function walk(json: Json, value: unknown, match: (json: Json) => boolean, leaf: Leaf, path: string): unknown {
|
|
18
|
+
if (value === undefined || value === null) return value;
|
|
19
|
+
if (match(json)) return leaf(json, value, path);
|
|
20
|
+
if (Array.isArray(value) && json.items && typeof json.items === "object") {
|
|
21
|
+
return value.map((x, i) => walk(json.items as Json, x, match, leaf, `${path}[${i}]`));
|
|
22
|
+
}
|
|
23
|
+
const props = json.properties as Record<string, Json> | undefined;
|
|
24
|
+
if (props && typeof value === "object" && !Array.isArray(value)) {
|
|
25
|
+
const out: Record<string, unknown> = {};
|
|
26
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
27
|
+
const p = props[k];
|
|
28
|
+
out[k] = p ? walk(p, v, match, leaf, path ? `${path}.${k}` : k) : v;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The field of a component's schema marked as its key: a property name, "" for a component that is its key, or null for none. */
|
|
36
|
+
export function keyField(json: Json): string | null {
|
|
37
|
+
if (json["x-key"] === true) return "";
|
|
38
|
+
const keys = keyFields(json);
|
|
39
|
+
return keys[0] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every top-level property marked `x-key`, in order: a component may have at most one. */
|
|
43
|
+
export function keyFields(json: Json): string[] {
|
|
44
|
+
const props = json.properties as Record<string, Json> | undefined;
|
|
45
|
+
return props ? Object.entries(props).filter(([, p]) => p["x-key"] === true).map(([k]) => k) : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every field of `value` whose schema names an entity of a component
|
|
50
|
+
* (`x-entity`), replaced by what `visit` answers for it: the runtime resolves
|
|
51
|
+
* a key to its entity there, and checks the entity carries the component.
|
|
52
|
+
*/
|
|
53
|
+
export function mapEntities(json: Json, value: unknown, visit: (component: string, value: unknown, path: string) => unknown): unknown {
|
|
54
|
+
return walk(json, value, (j) => typeof j["x-entity"] === "string", (j, v, p) => visit(j["x-entity"] as string, v, p), "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether the schema has a secret field anywhere in it. */
|
|
58
|
+
export function hasSecret(json: Json): boolean {
|
|
59
|
+
if (json.format === "secret") return true;
|
|
60
|
+
const props = json.properties as Record<string, Json> | undefined;
|
|
61
|
+
if (props && Object.values(props).some(hasSecret)) return true;
|
|
62
|
+
return typeof json.items === "object" && json.items !== null && hasSecret(json.items as Json);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `value` with every field its schema says is a secret replaced by •••. */
|
|
66
|
+
export function redact(json: Json, value: unknown): unknown {
|
|
67
|
+
return walk(json, value, (j) => j.format === "secret", () => REDACTED, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A literal example of a value the schema admits, for an agent to copy: every
|
|
72
|
+
* property, a string empty, an enum its first member, an entity 1, an array
|
|
73
|
+
* one item, a secret the redaction mark.
|
|
74
|
+
*/
|
|
75
|
+
export function exampleOf(json: Json): unknown {
|
|
76
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) return null;
|
|
77
|
+
const j = json as Record<string, unknown>;
|
|
78
|
+
if (Array.isArray(j.enum) && j.enum.length) return j.enum[0];
|
|
79
|
+
if (j.const !== undefined) return j.const;
|
|
80
|
+
const type = Array.isArray(j.type) ? j.type.find((t) => t !== "null") : j.type;
|
|
81
|
+
switch (type) {
|
|
82
|
+
case "object": return Object.fromEntries(Object.entries((j.properties as Record<string, Json> | undefined) ?? {}).map(([k, p]) => [k, exampleOf(p)]));
|
|
83
|
+
case "array": return [exampleOf(j.items as Json)];
|
|
84
|
+
case "string": return j.format === "secret" ? REDACTED : "";
|
|
85
|
+
case "integer": return j["x-entity"] !== undefined || j.description === "an entity" ? 1 : 0;
|
|
86
|
+
case "number": return 0;
|
|
87
|
+
case "boolean": return j.const === true ? true : false;
|
|
88
|
+
case "null": return null;
|
|
89
|
+
default: return Array.isArray(j.anyOf) && j.anyOf.length ? exampleOf(j.anyOf[0] as Json) : null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// A read-only copy of the state, from a whole snapshot and the deltas after
|
|
2
|
+
// it: what a window queries. The same storage and the same query code as the
|
|
3
|
+
// state's, so a view's `w.query` costs what a step's does. Declarations are
|
|
4
|
+
// matched by name, so the window imports the baba's own. A delta is applied
|
|
5
|
+
// as a patch: the parts it names put or taken away, the rest left alone.
|
|
6
|
+
// Change terms measure from the delta before the last one applied: what the
|
|
7
|
+
// last round changed.
|
|
8
|
+
// Pure, browser-safe.
|
|
9
|
+
import type { Component, Resource, Effect, Entity, Row, Snapshot, Value, Term, Values, Select } from "./state.ts";
|
|
10
|
+
import { Table } from "./table.ts";
|
|
11
|
+
import { isDelta, PRESENT, type StateAnswer } from "./snapshot.ts";
|
|
12
|
+
|
|
13
|
+
export class Replica<_X = any> {
|
|
14
|
+
round = 0;
|
|
15
|
+
private table = new Table();
|
|
16
|
+
private resources: Record<string, unknown> = {};
|
|
17
|
+
jobs: Snapshot["jobs"] = [];
|
|
18
|
+
/** What `added`, `changed` and `removed` terms measure from: the state before the last answer applied. */
|
|
19
|
+
private since = 0;
|
|
20
|
+
/** The round each resource was last written in, as far as the answers said. */
|
|
21
|
+
private rticks = new Map<string, number>();
|
|
22
|
+
/** What a select with change terms answered this round. */
|
|
23
|
+
private changed = new Map<Select, { round: number; ids: readonly Entity[] }>();
|
|
24
|
+
/** A column read once per version: the select made sensitive to its values, and the last answer. */
|
|
25
|
+
private columns = new Map<Select, Map<string, { sensitive: Select; version: number; map: ReadonlyMap<Entity, unknown> }>>();
|
|
26
|
+
|
|
27
|
+
constructor(snap?: Snapshot) { if (snap) this.apply(snap); }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A whole replaces everything; a delta since this round moves it forward.
|
|
31
|
+
* Answers the replica as of that round: a new object over the same
|
|
32
|
+
* storage, so a renderer that memoizes on it sees the change. The one
|
|
33
|
+
* applied to shares the storage and is not to be read after.
|
|
34
|
+
*/
|
|
35
|
+
apply(answer: StateAnswer): Replica<_X> {
|
|
36
|
+
this.since = this.table.seq;
|
|
37
|
+
if (isDelta(answer)) {
|
|
38
|
+
if (answer.since !== this.round) throw new Error(`a delta since round ${answer.since} does not apply to round ${this.round}`);
|
|
39
|
+
for (const [id, patch] of Object.entries(answer.entities)) {
|
|
40
|
+
const e = Number(id);
|
|
41
|
+
if (patch === null) { if (this.table.exists(e)) this.table.despawn(e); } else this.table.patch(e, patch);
|
|
42
|
+
}
|
|
43
|
+
Object.assign(this.resources, answer.resources);
|
|
44
|
+
for (const n of Object.keys(answer.resources)) this.rticks.set(n, answer.round);
|
|
45
|
+
if (answer.jobs !== undefined) this.jobs = answer.jobs;
|
|
46
|
+
} else {
|
|
47
|
+
for (const e of this.table.all()) if (!(String(e) in answer.entities)) this.table.despawn(e);
|
|
48
|
+
for (const [id, parts] of Object.entries(answer.entities)) this.table.setParts(Number(id), parts);
|
|
49
|
+
this.resources = { ...answer.resources };
|
|
50
|
+
for (const n of Object.keys(answer.resources)) this.rticks.set(n, answer.round);
|
|
51
|
+
this.jobs = answer.jobs;
|
|
52
|
+
}
|
|
53
|
+
this.round = answer.round;
|
|
54
|
+
return Object.assign(Object.create(Replica.prototype) as Replica<_X>, this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
alive(e: Entity): boolean { return this.table.exists(e); }
|
|
58
|
+
has(e: Entity, c: Component): boolean { return this.table.hasPart(e, c.name); }
|
|
59
|
+
get<K extends Component>(e: Entity, c: K): Value<K> | undefined;
|
|
60
|
+
get<K extends Resource>(r: K): Value<K>;
|
|
61
|
+
get(a: Entity | Resource, c?: Component): unknown {
|
|
62
|
+
if (typeof a === "number") { const v = this.table.get(a, c!.name); return v === PRESENT ? undefined : v; }
|
|
63
|
+
return this.resources[a.name];
|
|
64
|
+
}
|
|
65
|
+
/** The components any entity carries, by name. */
|
|
66
|
+
components(): Set<string> { return new Set(this.table.names()); }
|
|
67
|
+
/** Whether the value of `c` on `e` is held, not only its presence. */
|
|
68
|
+
known(e: Entity, c: Component): boolean { const v = this.table.get(e, c.name); return v !== undefined && v !== PRESENT; }
|
|
69
|
+
of(e: Entity): Record<string, unknown> { return this.table.partsOf(e); }
|
|
70
|
+
all(): Entity[] { return this.table.all(); }
|
|
71
|
+
count(c: Component): number { return this.table.countOf(c.name); }
|
|
72
|
+
running(effect: Effect, entity?: Entity): boolean { return this.jobs.some((j) => j.effect === effect.name && j.entity === (entity ?? null)); }
|
|
73
|
+
|
|
74
|
+
// ---- what a watcher compares ------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** The entities a select answers, in its order: the same array until something it depends on moves. A select with change terms answers what the last round changed. */
|
|
77
|
+
ids<Cs extends readonly Term[]>(sel: Select<Cs>): readonly Entity[] {
|
|
78
|
+
if (sel.terms.some((t) => (t as { kind: string }).kind === "changed")) {
|
|
79
|
+
const have = this.changed.get(sel as never);
|
|
80
|
+
if (have && have.round === this.round) return have.ids;
|
|
81
|
+
const ids: Entity[] = [];
|
|
82
|
+
this.table.each(sel as never, this.since, (e) => { ids.push(e); });
|
|
83
|
+
const frozen = ids.length ? Object.freeze(ids) : NONE;
|
|
84
|
+
this.changed.set(sel as never, { round: this.round, ids: frozen });
|
|
85
|
+
return frozen;
|
|
86
|
+
}
|
|
87
|
+
this.table.keep(sel as never);
|
|
88
|
+
return this.table.kept(sel as never);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One column of a select: every member's value of `c`, as one map, the
|
|
92
|
+
* same until a member comes, goes or is written. What a component that
|
|
93
|
+
* draws a whole layer reads, instead of a hook per row.
|
|
94
|
+
*/
|
|
95
|
+
column<K extends Component>(sel: Select, c: K): ReadonlyMap<Entity, Value<K>> {
|
|
96
|
+
let per = this.columns.get(sel);
|
|
97
|
+
if (!per) { per = new Map(); this.columns.set(sel, per); }
|
|
98
|
+
let col = per.get(c.name);
|
|
99
|
+
// A select with a predicate or an order is sensitive already; a plain one gets a twin that is, so the list's own version stays quiet on writes.
|
|
100
|
+
if (!col) { col = { sensitive: sel.pred !== null || sel.order !== null ? sel : sel.where(() => true), version: -1, map: new Map() }; per.set(c.name, col); }
|
|
101
|
+
this.table.keep(col.sensitive as never);
|
|
102
|
+
const version = this.table.version(col.sensitive as never);
|
|
103
|
+
if (version !== col.version) {
|
|
104
|
+
const map = new Map<Entity, Value<K>>();
|
|
105
|
+
for (const e of this.table.kept(col.sensitive as never)) { const v = this.table.get(e, c.name); if (v !== undefined && v !== PRESENT) map.set(e, v as Value<K>); }
|
|
106
|
+
col.version = version; col.map = map;
|
|
107
|
+
}
|
|
108
|
+
return col.map as ReadonlyMap<Entity, Value<K>>;
|
|
109
|
+
}
|
|
110
|
+
/** Another number whenever the select's answer may differ. */
|
|
111
|
+
version(sel: Select): number { this.table.keep(sel as never); return this.table.version(sel as never); }
|
|
112
|
+
/** When the value of `c` on `e` was last written, or -1 without one. */
|
|
113
|
+
tick(e: Entity, c: Component): number { return this.table.valueTick(e, c.name); }
|
|
114
|
+
/** When anything on `e` was last written. */
|
|
115
|
+
writeTick(e: Entity): number { return this.table.writeTick(e); }
|
|
116
|
+
/** The round the resource was last written in. */
|
|
117
|
+
resourceTick(r: Resource): number { return this.rticks.get(r.name) ?? 0; }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lets go of what a follower no longer asks for of component `name`: the
|
|
121
|
+
* values of every entity but those in `values` become held presences when
|
|
122
|
+
* `presence` is kept, else the parts go. A held part is written anew, so
|
|
123
|
+
* `changed(c)` lists it once; that is what letting go looks like.
|
|
124
|
+
*/
|
|
125
|
+
forget(name: string, keep: { presence: boolean; values: ReadonlySet<Entity> }): void {
|
|
126
|
+
for (const e of this.table.idsOf(name)) {
|
|
127
|
+
if (keep.values.has(e)) continue;
|
|
128
|
+
if (!keep.presence) { this.table.remove(e, name); continue; }
|
|
129
|
+
if (this.table.get(e, name) !== PRESENT) this.table.put(e, name, PRESENT);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
query<const Cs extends readonly Term[]>(...terms: Cs): Row<Cs>[];
|
|
134
|
+
query<Cs extends readonly Term[]>(sel: Select<Cs>): Row<Cs>[];
|
|
135
|
+
query(...terms: unknown[]): unknown[] {
|
|
136
|
+
const t = terms[0];
|
|
137
|
+
if (isSel(t)) { this.table.keep(t as never); return this.table.query(t as never, this.since); }
|
|
138
|
+
return this.table.query(terms as Term[], this.since);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
each<const Cs extends readonly Term[]>(terms: Cs, fn: (e: Entity, ...values: Values<Cs>) => void): void;
|
|
142
|
+
each<Cs extends readonly Term[]>(sel: Select<Cs>, fn: (e: Entity, ...values: Values<Cs>) => void): void;
|
|
143
|
+
each(terms: unknown, fn: (e: Entity, ...values: never[]) => void): void {
|
|
144
|
+
if (isSel(terms)) { this.table.keep(terms as never); this.table.each(terms as never, this.since, (e, v) => fn(e, ...(v as never[]))); return; }
|
|
145
|
+
this.table.each(terms as Term[], this.since, (e, v) => fn(e, ...(v as never[])));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const NONE: readonly Entity[] = Object.freeze([]);
|
|
150
|
+
const isSel = (x: unknown): x is Select => typeof x === "object" && x !== null && (x as { kind?: unknown }).kind === "select";
|