@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.
@@ -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' ||
@@ -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))
@@ -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.decode(addrA, true) : undefined
31
- const refsB = addrB !== undefined ? streamo.decode(addrB, true) : undefined
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 asRefs to avoid decoding untouched subtrees,
157
- // then rebuild only the changed path bottom-up, reusing sibling addresses.
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.asRefs(addr)
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.asRefs(addr), key: path[path.length - 1] })
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.asRefs(addr)
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.asRefs(addr), key: path[path.length - 1] })
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
- // When someone announces directly, subscribe to their repo immediately
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, set profile if first time
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
- myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
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
 
@@ -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
- return [
24
- this.v[0] instanceof Duple ? this.v[0].flat() : this.v[0],
25
- this.v[1] instanceof Duple ? this.v[1].flat() : this.v[1]
26
- ].flat()
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
- return r.addressOf(code) ?? r.append(code)
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
- const proto = Object.getPrototypeOf(v)
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
- return { UNDEFINED, NULL, FALSE, TRUE, WORD, UINT8ARRAY, EMPTY_STRING, STRING, UINT7, FLOAT64, DATE, SIGNATURE, DUPLE, EMPTY_ARRAY, ARRAY, EMPTY_OBJECT, OBJECT, VARIABLE }
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