@dtudury/streamo 3.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -23
- package/package.json +3 -3
- package/public/apps/chat/main.js +22 -3
- package/public/apps/explorer/index.html +215 -6
- package/public/apps/explorer/main.js +859 -155
- package/public/streamo/Addressifier.js +9 -0
- package/public/streamo/CodecRegistry.js +95 -0
- package/public/streamo/Repo.js +20 -2
- package/public/streamo/Signer.js +10 -0
- package/public/streamo/Streamo.js +43 -9
- package/public/streamo/chat-cli.js +20 -4
- package/public/streamo/codecs.js +49 -9
- package/public/streamo/registrySync.js +11 -0
- package/public/streamo/utils/Recaller.js +11 -0
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Addressifier — append-only content-addressable byte store.
|
|
3
|
+
*
|
|
4
|
+
* The byte-level foundation. Knows nothing about types or values; just
|
|
5
|
+
* appends Uint8Array chunks and indexes them by content. Higher layers
|
|
6
|
+
* (CodecRegistry, Streamo, Repo, registrySync) build on this.
|
|
7
|
+
*
|
|
8
|
+
* See design.md §1.
|
|
9
|
+
*/
|
|
1
10
|
import { ContentMap } from './ContentMap.js'
|
|
2
11
|
|
|
3
12
|
/**
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CodecRegistry — codec dispatcher on top of Addressifier.
|
|
3
|
+
*
|
|
4
|
+
* Resolves bytes ↔ JS values via a registered codec table; chunks identify
|
|
5
|
+
* their codec by their last byte (the footer). Public read APIs (asRefs,
|
|
6
|
+
* directReferences, decode) are mutation-impossible by construction; the
|
|
7
|
+
* write companion _asRefsForWrite is internal.
|
|
8
|
+
*
|
|
9
|
+
* See design.md §3–4.
|
|
10
|
+
*/
|
|
1
11
|
import { Addressifier } from './Addressifier.js'
|
|
2
12
|
import { makeCodecs } from './codecs.js'
|
|
3
13
|
|
|
@@ -21,6 +31,9 @@ export class CodecRegistry extends Addressifier {
|
|
|
21
31
|
footerToCodec = []
|
|
22
32
|
|
|
23
33
|
#codecs
|
|
34
|
+
// Read-only scope depth — only mutated by #runReadOnly below, never
|
|
35
|
+
// touched directly. The codec interface exposes it as r.readOnly (boolean).
|
|
36
|
+
#readOnlyDepth = 0
|
|
24
37
|
|
|
25
38
|
constructor () {
|
|
26
39
|
super()
|
|
@@ -32,11 +45,27 @@ export class CodecRegistry extends Addressifier {
|
|
|
32
45
|
resolve: addr => self.resolve(addr),
|
|
33
46
|
addressOf: code => self.addressOf(code),
|
|
34
47
|
get byteLength () { return self.byteLength },
|
|
48
|
+
get readOnly () { return self.#readOnlyDepth > 0 },
|
|
35
49
|
footerToCodec: this.footerToCodec
|
|
36
50
|
})
|
|
37
51
|
this.#registerAll()
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Run `fn` in a read-only scope. Codec helpers see `r.readOnly === true`
|
|
56
|
+
* for the duration and avoid mutation paths (specifically getPartAddress
|
|
57
|
+
* in codecs.js returns undefined instead of materializing inline
|
|
58
|
+
* children). Used by asRefs.
|
|
59
|
+
*
|
|
60
|
+
* The depth counter handles the case where `fn` itself re-enters this
|
|
61
|
+
* method — outer scopes stay read-only until they all unwind.
|
|
62
|
+
*/
|
|
63
|
+
#runReadOnly (fn) {
|
|
64
|
+
this.#readOnlyDepth++
|
|
65
|
+
try { return fn() }
|
|
66
|
+
finally { this.#readOnlyDepth-- }
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
// Re-expose byteLength so subclasses can override it
|
|
41
70
|
get byteLength () { return super.byteLength }
|
|
42
71
|
|
|
@@ -110,6 +139,72 @@ export class CodecRegistry extends Addressifier {
|
|
|
110
139
|
* @returns {Object|Array|number}
|
|
111
140
|
*/
|
|
112
141
|
asRefs (address) {
|
|
142
|
+
const code = this.resolve(address)
|
|
143
|
+
const { type } = this.footerToCodec[code.at(-1)]
|
|
144
|
+
if (type === 'VARIABLE' ||
|
|
145
|
+
type === 'OBJECT' || type === 'EMPTY_OBJECT' ||
|
|
146
|
+
type === 'ARRAY' || type === 'EMPTY_ARRAY') {
|
|
147
|
+
// Decode in a read-only scope so codecs cannot materialize inline
|
|
148
|
+
// children. Inline addresses come back as `undefined`; callers
|
|
149
|
+
// handle that (e.g. by rendering the child without a clickable
|
|
150
|
+
// link). Mutation is unreachable from here regardless of caller —
|
|
151
|
+
// by control flow, not by caller discipline.
|
|
152
|
+
return this.#runReadOnly(() => this.decode(address, true))
|
|
153
|
+
}
|
|
154
|
+
return address
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Direct chunk-graph references — the addresses this chunk's bytes point
|
|
159
|
+
* to (NOT the user-level child values asRefs returns). Walks the codec's
|
|
160
|
+
* parts: addressed parts contribute their target address; inline parts
|
|
161
|
+
* are skipped (their bytes are embedded in this chunk, no separate
|
|
162
|
+
* address). Pure read; never mutates.
|
|
163
|
+
*
|
|
164
|
+
* What you see varies by codec:
|
|
165
|
+
* - DUPLE → up to 2 references (left, right)
|
|
166
|
+
* - OBJECT/ARRAY/VARIABLE → 1 reference (the embedded Duple-tree or
|
|
167
|
+
* wrapped value, when stored separately)
|
|
168
|
+
* - STRING/UINT8ARRAY/DATE/FLOAT64 → 1 reference (the encoded bytes,
|
|
169
|
+
* when stored separately)
|
|
170
|
+
* - WORD, UINT7, EMPTY_*, primitives → none
|
|
171
|
+
* - SIGNATURE → none (its parts are data, not chunk references)
|
|
172
|
+
*
|
|
173
|
+
* Used by the explorer's storage tab to walk the chunk graph.
|
|
174
|
+
*
|
|
175
|
+
* @param {number} address
|
|
176
|
+
* @returns {number[]}
|
|
177
|
+
*/
|
|
178
|
+
directReferences (address) {
|
|
179
|
+
const code = this.resolve(address)
|
|
180
|
+
const codec = this.footerToCodec[code.at(-1)]
|
|
181
|
+
if (!codec?.partReaders?.length) return []
|
|
182
|
+
|
|
183
|
+
const refs = []
|
|
184
|
+
const footer = code.at(-1)
|
|
185
|
+
let option = footer - codec.baseFooter
|
|
186
|
+
let end = -1
|
|
187
|
+
for (let i = codec.partReaders.length - 1; i >= 0; i--) {
|
|
188
|
+
const opts = codec.partReaders[i]
|
|
189
|
+
const reader = opts[option % opts.length]
|
|
190
|
+
option = Math.floor(option / opts.length)
|
|
191
|
+
const part = reader(code.subarray(0, end))
|
|
192
|
+
end -= part.width
|
|
193
|
+
if (part.address !== undefined) refs.unshift(part.address)
|
|
194
|
+
}
|
|
195
|
+
return refs
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Internal: like asRefs but materializes inline children when needed
|
|
200
|
+
* (write context). Used by Streamo.set / setRefs during path traversal,
|
|
201
|
+
* which DOES need real addresses to navigate composite values; and the
|
|
202
|
+
* mutation it triggers is part of the same write op anyway. Public callers
|
|
203
|
+
* should use asRefs (above), which is mutation-impossible.
|
|
204
|
+
* @param {number} address
|
|
205
|
+
* @returns {Object|Array|number}
|
|
206
|
+
*/
|
|
207
|
+
_asRefsForWrite (address) {
|
|
113
208
|
const code = this.resolve(address)
|
|
114
209
|
const { type } = this.footerToCodec[code.at(-1)]
|
|
115
210
|
if (type === 'VARIABLE' ||
|
package/public/streamo/Repo.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Repo — a Streamo whose every set() becomes a signed commit.
|
|
3
|
+
*
|
|
4
|
+
* Each commit is a record { message, date, dataAddress, parent }. The
|
|
5
|
+
* commit log is what flows over the wire during sync. attachSigner
|
|
6
|
+
* makes commits sign automatically, with concurrent commits batched
|
|
7
|
+
* into one signature.
|
|
8
|
+
*
|
|
9
|
+
* See design.md §8.
|
|
10
|
+
*/
|
|
1
11
|
import { Recaller } from './utils/Recaller.js'
|
|
2
12
|
import { Streamo, changedPaths } from './Streamo.js'
|
|
3
13
|
|
|
@@ -23,6 +33,14 @@ export class Repo extends Streamo {
|
|
|
23
33
|
#signing = false
|
|
24
34
|
#signPending = false
|
|
25
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Default commit message attached to every commit made via set() / setRefs().
|
|
38
|
+
* Empty by default — clients opt in to set this for attribution. The chat web
|
|
39
|
+
* client sets 'web' so commits are visibly distinguishable from a CLI
|
|
40
|
+
* client's. Not enforced; explicit commit(working, msg) wins.
|
|
41
|
+
*/
|
|
42
|
+
defaultMessage = ''
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Attach a signer so every commit is automatically signed.
|
|
28
46
|
* Concurrent commits are batched: if a sign is in flight when another
|
|
@@ -109,7 +127,7 @@ export class Repo extends Streamo {
|
|
|
109
127
|
const prevDataAddress = this.lastCommit?.dataAddress
|
|
110
128
|
const working = this.checkout()
|
|
111
129
|
working.set(...args)
|
|
112
|
-
const result = this.commit(working)
|
|
130
|
+
const result = this.commit(working, this.defaultMessage)
|
|
113
131
|
const newDataAddress = this.lastCommit?.dataAddress
|
|
114
132
|
for (const changed of changedPaths(this, prevDataAddress, newDataAddress)) {
|
|
115
133
|
this.recaller.reportKeyMutation(this, JSON.stringify(changed))
|
|
@@ -146,7 +164,7 @@ export class Repo extends Streamo {
|
|
|
146
164
|
const prevDataAddress = this.lastCommit?.dataAddress
|
|
147
165
|
const working = this.checkout()
|
|
148
166
|
working.setRefs(...args)
|
|
149
|
-
const result = this.commit(working)
|
|
167
|
+
const result = this.commit(working, this.defaultMessage)
|
|
150
168
|
const newDataAddress = this.lastCommit?.dataAddress
|
|
151
169
|
for (const changed of changedPaths(this, prevDataAddress, newDataAddress)) {
|
|
152
170
|
this.recaller.reportKeyMutation(this, JSON.stringify(changed))
|
package/public/streamo/Signer.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Signer — deterministic secp256k1 keypairs from credentials.
|
|
3
|
+
*
|
|
4
|
+
* PBKDF2-SHA256 (256 bits) is used twice — first to derive a hashword
|
|
5
|
+
* from password+username, then a private key per stream-name. No key
|
|
6
|
+
* files; same credentials always produce the same identity. KAT in
|
|
7
|
+
* Signer.test.js pins the byte output across runtime versions.
|
|
8
|
+
*
|
|
9
|
+
* See design.md §7.
|
|
10
|
+
*/
|
|
1
11
|
import { getPublicKey, signAsync, verify } from './utils/noble-secp256k1.js'
|
|
2
12
|
|
|
3
13
|
const cryptoSubtle = typeof crypto !== 'undefined' ? crypto.subtle : (await import('crypto')).webcrypto.subtle
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Streamo — reactive content-addressed signed byte store.
|
|
3
|
+
*
|
|
4
|
+
* Layers Recaller-driven path-level reactivity on top of CodecRegistry,
|
|
5
|
+
* plus a sign/verify API for secp256k1 attestations over byte ranges.
|
|
6
|
+
* `valueAddress` skips trailing SIGNATURE chunks so reading the latest
|
|
7
|
+
* value works whether or not it has been auto-signed yet.
|
|
8
|
+
*
|
|
9
|
+
* Exports: Streamo (the class), ConflictError, changedPaths.
|
|
10
|
+
*
|
|
11
|
+
* See design.md §5.
|
|
12
|
+
*/
|
|
1
13
|
import { Recaller } from './utils/Recaller.js'
|
|
2
14
|
import { CodecRegistry } from './CodecRegistry.js'
|
|
3
15
|
import { Signature } from './Signature.js'
|
|
@@ -23,12 +35,24 @@ export class ConflictError extends Error {
|
|
|
23
35
|
/**
|
|
24
36
|
* Yield every path where addrA and addrB differ, including the root.
|
|
25
37
|
* Compares by address so unchanged subtrees are skipped in O(1).
|
|
38
|
+
*
|
|
39
|
+
* Uses streamo.asRefs (mutation-impossible) rather than decode(_, true)
|
|
40
|
+
* so the comparison cannot append chunks. Without this, calling
|
|
41
|
+
* changedPaths during Streamo.set could materialize inline children as
|
|
42
|
+
* separate chunks AFTER the new commit, moving valueAddress past the
|
|
43
|
+
* commit and corrupting Repo.lastCommit.
|
|
44
|
+
*
|
|
45
|
+
* Tradeoff: asRefs returns `undefined` for inline children's addresses,
|
|
46
|
+
* so changedPaths can't see differences that happen entirely inside
|
|
47
|
+
* inline-only subtrees. The parent path still fires, which is enough
|
|
48
|
+
* for any watcher that doesn't read at a depth past where the structure
|
|
49
|
+
* goes inline.
|
|
26
50
|
*/
|
|
27
51
|
export function * changedPaths (streamo, addrA, addrB, path = []) {
|
|
28
52
|
if (addrA === addrB) return
|
|
29
53
|
yield path
|
|
30
|
-
const refsA = addrA !== undefined ? streamo.
|
|
31
|
-
const refsB = addrB !== undefined ? streamo.
|
|
54
|
+
const refsA = addrA !== undefined ? streamo.asRefs(addrA) : undefined
|
|
55
|
+
const refsB = addrB !== undefined ? streamo.asRefs(addrB) : undefined
|
|
32
56
|
const isPlain = v => v != null && typeof v === 'object' && (Array.isArray(v) || Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
|
|
33
57
|
const objA = isPlain(refsA)
|
|
34
58
|
const objB = isPlain(refsB)
|
|
@@ -86,6 +110,14 @@ export class Streamo extends CodecRegistry {
|
|
|
86
110
|
*/
|
|
87
111
|
append (code) {
|
|
88
112
|
const address = super.append(code)
|
|
113
|
+
// If the appended chunk is a SIGNATURE, advance the signed cursor — no
|
|
114
|
+
// matter whether the caller was sign(), an archive replay, or a peer
|
|
115
|
+
// stream. Otherwise a fresh load (signedLength=0) followed by a new
|
|
116
|
+
// sign() would re-sign all of history, producing a sig whose signedFrom
|
|
117
|
+
// collides with every prior sig.
|
|
118
|
+
if (this.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
|
|
119
|
+
this.#signedLength = super.byteLength - code.length
|
|
120
|
+
}
|
|
89
121
|
this.#recaller.reportKeyMutation(this, 'length')
|
|
90
122
|
return address
|
|
91
123
|
}
|
|
@@ -153,16 +185,19 @@ export class Streamo extends CodecRegistry {
|
|
|
153
185
|
}
|
|
154
186
|
super.append(this.encode(encodedValue))
|
|
155
187
|
} else {
|
|
156
|
-
// Path update: navigate via
|
|
157
|
-
// then rebuild only the changed path bottom-up, reusing sibling
|
|
188
|
+
// Path update: navigate via _asRefsForWrite to avoid decoding untouched
|
|
189
|
+
// subtrees, then rebuild only the changed path bottom-up, reusing sibling
|
|
190
|
+
// addresses. (The public asRefs is mutation-impossible; the internal
|
|
191
|
+
// _asRefsForWrite allows materializing inline children, which is
|
|
192
|
+
// appropriate here because we're inside a write op.)
|
|
158
193
|
const levels = []
|
|
159
194
|
let addr = baseAddress
|
|
160
195
|
for (let i = 0; i < path.length - 1; i++) {
|
|
161
|
-
const refs = this.
|
|
196
|
+
const refs = this._asRefsForWrite(addr)
|
|
162
197
|
levels.push({ refs, key: path[i] })
|
|
163
198
|
addr = Array.isArray(refs) ? refs[+path[i]] : refs[path[i]]
|
|
164
199
|
}
|
|
165
|
-
levels.push({ refs: this.
|
|
200
|
+
levels.push({ refs: this._asRefsForWrite(addr), key: path[path.length - 1] })
|
|
166
201
|
|
|
167
202
|
// Encode the new leaf value
|
|
168
203
|
const leafCode = this.encode(value)
|
|
@@ -224,11 +259,11 @@ export class Streamo extends CodecRegistry {
|
|
|
224
259
|
const levels = []
|
|
225
260
|
let addr = baseAddress
|
|
226
261
|
for (let i = 0; i < path.length - 1; i++) {
|
|
227
|
-
const refs = this.
|
|
262
|
+
const refs = this._asRefsForWrite(addr)
|
|
228
263
|
levels.push({ refs, key: path[i] })
|
|
229
264
|
addr = Array.isArray(refs) ? refs[+path[i]] : refs[path[i]]
|
|
230
265
|
}
|
|
231
|
-
levels.push({ refs: this.
|
|
266
|
+
levels.push({ refs: this._asRefsForWrite(addr), key: path[path.length - 1] })
|
|
232
267
|
|
|
233
268
|
for (let i = levels.length - 1; i >= 0; i--) {
|
|
234
269
|
const { refs, key } = levels[i]
|
|
@@ -337,7 +372,6 @@ export class Streamo extends CodecRegistry {
|
|
|
337
372
|
if (super.byteLength !== before) throw new Error('streamo was modified while signing')
|
|
338
373
|
const sig = new Signature(this.#signedLength, compactRawBytes)
|
|
339
374
|
this.append(this.encode(sig))
|
|
340
|
-
this.#signedLength = before
|
|
341
375
|
return sig
|
|
342
376
|
}
|
|
343
377
|
|
|
@@ -42,21 +42,35 @@ console.log(`root key: ${rootKey.slice(0, 16)}…`)
|
|
|
42
42
|
console.log('─'.repeat(40))
|
|
43
43
|
|
|
44
44
|
const registry = new RepoRegistry()
|
|
45
|
+
// Track who we've announced ourselves back to (deduped to prevent
|
|
46
|
+
// ping-pong) — see comment in chat/main.js for the discovery pattern.
|
|
47
|
+
const announcedTo = new Set()
|
|
45
48
|
const session = await registrySync(registry, host, port, {
|
|
46
49
|
filter: k => k === rootKey,
|
|
47
50
|
follow: (keyHex, repo, subscribe) => {
|
|
48
|
-
// Auto-follow all members listed in the root repo
|
|
51
|
+
// Auto-follow all members listed in the root repo (only present when
|
|
52
|
+
// the server has chat-room onAnnounce wiring).
|
|
49
53
|
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
50
54
|
},
|
|
51
55
|
onAnnounce: (key) => {
|
|
52
|
-
//
|
|
56
|
+
// Subscribe to the announcer AND announce ourselves back so they
|
|
57
|
+
// learn we exist. Makes chat work even when the server has no
|
|
58
|
+
// member-tracking onAnnounce of its own.
|
|
53
59
|
session.subscribe(key)
|
|
60
|
+
if (!announcedTo.has(key)) {
|
|
61
|
+
announcedTo.add(key)
|
|
62
|
+
session.announce(myKey, rootKey)
|
|
63
|
+
}
|
|
54
64
|
}
|
|
55
65
|
})
|
|
56
66
|
|
|
57
|
-
// Open my own repo
|
|
67
|
+
// Open my own repo and attach our signer so commits go out signed —
|
|
68
|
+
// "every write is provably yours" only holds if we actually sign. Set
|
|
69
|
+
// profile on first run.
|
|
58
70
|
const myRepo = await registry.open(myKey)
|
|
71
|
+
myRepo.attachSigner(signer, 'chat')
|
|
59
72
|
if (!myRepo.get('name')) {
|
|
73
|
+
myRepo.defaultMessage = `joined as ${username} (cli)`
|
|
60
74
|
myRepo.set({ name: username, messages: [] })
|
|
61
75
|
}
|
|
62
76
|
|
|
@@ -110,7 +124,9 @@ rl.on('line', async line => {
|
|
|
110
124
|
const text = line.trim()
|
|
111
125
|
if (!text) { rl.prompt(); return }
|
|
112
126
|
const messages = myRepo.get('messages') ?? []
|
|
113
|
-
|
|
127
|
+
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
128
|
+
myRepo.defaultMessage = `"${preview}" (cli)`
|
|
129
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
|
|
114
130
|
rl.prompt()
|
|
115
131
|
})
|
|
116
132
|
|
package/public/streamo/codecs.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file codecs — concrete codecs for every value type Streamo can encode.
|
|
3
|
+
*
|
|
4
|
+
* Primitives (UNDEFINED, NULL, FALSE, TRUE, UINT7, FLOAT64, DATE), bytes
|
|
5
|
+
* (WORD, UINT8ARRAY, EMPTY_UINT8ARRAY), strings, composites (OBJECT,
|
|
6
|
+
* ARRAY, EMPTY_*), the SIGNATURE chunk, and the internal balanced-tree
|
|
7
|
+
* node Duple used to scale OBJECT/ARRAY storage. Every codec is a
|
|
8
|
+
* { encode, decode, partReaders } object.
|
|
9
|
+
*
|
|
10
|
+
* See design.md §3.
|
|
11
|
+
*/
|
|
1
12
|
import { numberToVar, varToNumber, range } from './utils.js'
|
|
2
13
|
import { Signature } from './Signature.js'
|
|
3
14
|
|
|
@@ -20,10 +31,16 @@ class Duple {
|
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
flat () {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
].
|
|
34
|
+
// Walk the Duple tree, flattening only Duple nodes — never nested user
|
|
35
|
+
// values. (Earlier code used Array.prototype.flat() which silently
|
|
36
|
+
// flattened any nested array a caller had stored, so e.g. [3, [4,5]]
|
|
37
|
+
// would round-trip as [3, 4, 5].)
|
|
38
|
+
const out = []
|
|
39
|
+
for (const child of this.v) {
|
|
40
|
+
if (child instanceof Duple) out.push(...child.flat())
|
|
41
|
+
else out.push(child)
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
flatDuples () {
|
|
@@ -158,12 +175,23 @@ export function makeCodecs (r) {
|
|
|
158
175
|
return parts
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
// Stable address of a single-part value (needed for DUPLE decode with asRefs)
|
|
178
|
+
// Stable address of a single-part value (needed for DUPLE decode with asRefs).
|
|
179
|
+
//
|
|
180
|
+
// For inline multi-byte parts that aren't independently stored, the only
|
|
181
|
+
// way to "give back an address" is to materialize them as a separate chunk —
|
|
182
|
+
// i.e. mutate. That's appropriate in write contexts (Streamo.set) but a
|
|
183
|
+
// bug from a read context. When the registry is in read-only mode (set by
|
|
184
|
+
// CodecRegistry.asRefs around its decode), we return undefined instead of
|
|
185
|
+
// appending. The caller (asRefs's caller, e.g. the explorer) sees an
|
|
186
|
+
// undefined child address and renders it as inline.
|
|
162
187
|
function getPartAddress (part) {
|
|
163
188
|
if (part.address !== undefined) return part.address
|
|
164
189
|
const code = part.getCode()
|
|
165
190
|
if (code.length === 1) return -(code[0] + 1) // negative address for single-byte primitives
|
|
166
|
-
|
|
191
|
+
const existing = r.addressOf(code)
|
|
192
|
+
if (existing !== undefined) return existing
|
|
193
|
+
if (r.readOnly) return undefined
|
|
194
|
+
return r.append(code)
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
// ── Codec definitions ────────────────────────────────────────────────────
|
|
@@ -353,10 +381,14 @@ export function makeCodecs (r) {
|
|
|
353
381
|
}
|
|
354
382
|
|
|
355
383
|
const EMPTY_OBJECT = {
|
|
384
|
+
// Accepts any non-array object with no own enumerable keys, including class
|
|
385
|
+
// instances. (OBJECT also doesn't check the prototype on encode, so empty
|
|
386
|
+
// class instances should be encodable too — keeping them symmetric.) Type
|
|
387
|
+
// information is lost on round-trip in both cases; the decoded value is a
|
|
388
|
+
// plain {}.
|
|
356
389
|
encode (v) {
|
|
357
390
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return
|
|
358
|
-
|
|
359
|
-
if (proto !== Object.prototype && proto !== null) return
|
|
391
|
+
if (v instanceof Uint8Array || v instanceof Date) return
|
|
360
392
|
if (Object.keys(v).length === 0) return new Uint8Array([EMPTY_OBJECT.baseFooter])
|
|
361
393
|
},
|
|
362
394
|
decode: () => ({})
|
|
@@ -396,5 +428,13 @@ export function makeCodecs (r) {
|
|
|
396
428
|
}
|
|
397
429
|
}
|
|
398
430
|
|
|
399
|
-
|
|
431
|
+
// Empty-Uint8Array codec is appended at the END of the registration list so
|
|
432
|
+
// it doesn't shift the footer values of existing codecs — chunks created
|
|
433
|
+
// before this codec was added still decode correctly.
|
|
434
|
+
const EMPTY_UINT8ARRAY = {
|
|
435
|
+
encode: v => v instanceof Uint8Array && v.length === 0 && new Uint8Array([EMPTY_UINT8ARRAY.baseFooter]),
|
|
436
|
+
decode: () => new Uint8Array(0)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { UNDEFINED, NULL, FALSE, TRUE, WORD, UINT8ARRAY, EMPTY_STRING, STRING, UINT7, FLOAT64, DATE, SIGNATURE, DUPLE, EMPTY_ARRAY, ARRAY, EMPTY_OBJECT, OBJECT, VARIABLE, EMPTY_UINT8ARRAY }
|
|
400
440
|
}
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file registrySync — bidirectional multi-repo WebSocket sync.
|
|
3
|
+
*
|
|
4
|
+
* After a "registry" handshake, both sides exchange JSON catalog/
|
|
5
|
+
* subscribe/interest/announce/ping messages and binary
|
|
6
|
+
* [33-byte-key-prefix][chunk] frames. Discovery happens via filter,
|
|
7
|
+
* follow (content-driven), or onAnnounce. 20-second keep-alive ping
|
|
8
|
+
* for PaaS hosts that idle-close.
|
|
9
|
+
*
|
|
10
|
+
* See design.md §10.
|
|
11
|
+
*/
|
|
1
12
|
// Use native WebSocket in the browser; fall back to the `ws` package in Node.
|
|
2
13
|
const WS = globalThis.WebSocket ?? (await import('ws')).default
|
|
3
14
|
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Recaller — fine-grained reactive dependency tracker.
|
|
3
|
+
*
|
|
4
|
+
* watch(name, fn) runs fn on a tracking stack; reportKeyAccess(target,
|
|
5
|
+
* key) inside fn registers (target, key) → fn; reportKeyMutation
|
|
6
|
+
* elsewhere wakes any matching watcher on the next microtask. The flush
|
|
7
|
+
* loop is robust against unwatch happening mid-flight, which matters
|
|
8
|
+
* for slot watchers torn down during DOM reconciliation.
|
|
9
|
+
*
|
|
10
|
+
* See design.md §6.
|
|
11
|
+
*/
|
|
1
12
|
import { NestedSet } from './NestedSet.js'
|
|
2
13
|
import { nextTick } from './nextTick.js'
|
|
3
14
|
|