@dtudury/streamo 0.1.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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +661 -0
  3. package/README.md +194 -0
  4. package/ROADMAP.md +111 -0
  5. package/bin/streamo.js +238 -0
  6. package/jsconfig.json +9 -0
  7. package/package.json +26 -0
  8. package/public/apps/chat/index.html +61 -0
  9. package/public/apps/chat/main.js +144 -0
  10. package/public/apps/styles/proto.css +71 -0
  11. package/public/index.html +109 -0
  12. package/public/streamo/Addressifier.js +212 -0
  13. package/public/streamo/CodecRegistry.js +195 -0
  14. package/public/streamo/ContentMap.js +79 -0
  15. package/public/streamo/DESIGN.md +61 -0
  16. package/public/streamo/Repo.js +176 -0
  17. package/public/streamo/Repo.test.js +82 -0
  18. package/public/streamo/RepoRegistry.js +91 -0
  19. package/public/streamo/RepoRegistry.test.js +87 -0
  20. package/public/streamo/Signature.js +15 -0
  21. package/public/streamo/Signer.js +91 -0
  22. package/public/streamo/Streamo.js +392 -0
  23. package/public/streamo/Streamo.test.js +205 -0
  24. package/public/streamo/archiveSync.js +62 -0
  25. package/public/streamo/chat-cli.js +122 -0
  26. package/public/streamo/chat-server.js +60 -0
  27. package/public/streamo/codecs.js +400 -0
  28. package/public/streamo/fileSync.js +238 -0
  29. package/public/streamo/h.js +202 -0
  30. package/public/streamo/h.mount.test.js +67 -0
  31. package/public/streamo/h.test.js +121 -0
  32. package/public/streamo/mount.js +248 -0
  33. package/public/streamo/originSync.js +60 -0
  34. package/public/streamo/outletSync.js +105 -0
  35. package/public/streamo/registrySync.js +333 -0
  36. package/public/streamo/registrySync.test.js +373 -0
  37. package/public/streamo/s3Sync.js +99 -0
  38. package/public/streamo/stateFileSync.js +17 -0
  39. package/public/streamo/sync.test.js +98 -0
  40. package/public/streamo/utils/NestedSet.js +41 -0
  41. package/public/streamo/utils/Recaller.js +77 -0
  42. package/public/streamo/utils/mockDOM.js +113 -0
  43. package/public/streamo/utils/nextTick.js +22 -0
  44. package/public/streamo/utils/noble-secp256k1.js +602 -0
  45. package/public/streamo/utils/testing.js +90 -0
  46. package/public/streamo/utils.js +57 -0
  47. package/public/streamo/webSync.js +118 -0
  48. package/scripts/serve.js +15 -0
  49. package/smoke.test.js +132 -0
@@ -0,0 +1,195 @@
1
+ import { Addressifier } from './Addressifier.js'
2
+ import { makeCodecs } from './codecs.js'
3
+
4
+ /**
5
+ * Extends Addressifier with a codec system.
6
+ *
7
+ * Each stored value is a Uint8Array whose last byte (the footer) identifies
8
+ * its codec. Footers are assigned sequentially as codecs are registered.
9
+ * Multi-part codecs multiply their option counts to produce a footer range
10
+ * (mixed-radix offset from baseFooter).
11
+ *
12
+ * Negative addresses encode single-byte primitive values without appending:
13
+ * address = -(footer + 1) → footer = -address - 1
14
+ * So UNDEFINED, NULL, FALSE, TRUE, and every UINT7 value are addressable
15
+ * without touching the store.
16
+ *
17
+ * Codecs are built by makeCodecs() in codecs.js and wired in here.
18
+ */
19
+ export class CodecRegistry extends Addressifier {
20
+ /** @type {Array} footer → codec */
21
+ footerToCodec = []
22
+
23
+ #codecs
24
+
25
+ constructor () {
26
+ super()
27
+ const self = this
28
+ this.#codecs = makeCodecs({
29
+ encode: (v, asRefs) => self.encode(v, asRefs),
30
+ decode: (code, asRefs) => self.decode(code, asRefs),
31
+ append: code => self.#appendSubcode(code),
32
+ resolve: addr => self.resolve(addr),
33
+ addressOf: code => self.addressOf(code),
34
+ get byteLength () { return self.byteLength },
35
+ footerToCodec: this.footerToCodec
36
+ })
37
+ this.#registerAll()
38
+ }
39
+
40
+ // Re-expose byteLength so subclasses can override it
41
+ get byteLength () { return super.byteLength }
42
+
43
+ /**
44
+ * Resolve an address to its Uint8Array.
45
+ * Negative addresses map to single-byte codes: -(footer+1) → [footer].
46
+ * @param {number} address
47
+ * @returns {Uint8Array}
48
+ */
49
+ resolve (address) {
50
+ if (address < 0) return new Uint8Array([-address - 1])
51
+ return super.resolve(address)
52
+ }
53
+
54
+ /**
55
+ * Decode any JS value from a code (Uint8Array) or address (number).
56
+ * @param {Uint8Array|number} codeOrAddress
57
+ * @param {boolean|boolean[]} [asRefs=false]
58
+ * @returns {any}
59
+ */
60
+ decode (codeOrAddress, asRefs = false) {
61
+ const code = typeof codeOrAddress === 'number'
62
+ ? this.resolve(codeOrAddress)
63
+ : codeOrAddress
64
+ if (!(code instanceof Uint8Array)) throw new Error('expected Uint8Array')
65
+ const codec = this.footerToCodec[code.at(-1)]
66
+ return codec.decode(code, asRefs)
67
+ }
68
+
69
+ /**
70
+ * Encode a JS value to a Uint8Array.
71
+ *
72
+ * When `asRefs` is truthy and `value` is a number it is treated as an
73
+ * address and resolved directly — this is the inverse of asRefs(), letting
74
+ * callers round-trip through asRefs → encode without deserialising subtrees.
75
+ *
76
+ * @param {any} value
77
+ * @param {boolean|boolean[]|string} [asRefs]
78
+ * @returns {Uint8Array}
79
+ */
80
+ encode (value, asRefs) {
81
+ if (asRefs && typeof value === 'number') return this.resolve(value)
82
+ for (const name in this.#codecs) {
83
+ const codec = this.#codecs[name]
84
+ const code = codec.encode?.(value, asRefs)
85
+ if (code) return code
86
+ }
87
+ throw new Error(`no codec for value: ${value}`)
88
+ }
89
+
90
+ /**
91
+ * Encode a value as a VARIABLE (boxed address) so that changing the
92
+ * top-level value is representable as a new append.
93
+ * @param {any} value
94
+ * @returns {Uint8Array}
95
+ */
96
+ encodeVariable (value) {
97
+ return this.#codecs.VARIABLE._encode(this.encode(value))
98
+ }
99
+
100
+ /**
101
+ * Return the immediate children of the value at `address` as addresses
102
+ * rather than decoded values:
103
+ * Object → { key: valueAddress } (names stay as strings)
104
+ * Array → [ addr0, addr1, … ]
105
+ * Other → address itself
106
+ *
107
+ * Useful for structural comparison without fully deserialising large trees.
108
+ *
109
+ * @param {number} address
110
+ * @returns {Object|Array|number}
111
+ */
112
+ asRefs (address) {
113
+ const code = this.resolve(address)
114
+ const { type } = this.footerToCodec[code.at(-1)]
115
+ if (type === 'VARIABLE' ||
116
+ type === 'OBJECT' || type === 'EMPTY_OBJECT' ||
117
+ type === 'ARRAY' || type === 'EMPTY_ARRAY') {
118
+ return this.decode(address, true)
119
+ }
120
+ return address
121
+ }
122
+
123
+ /**
124
+ * Copy a value from another CodecRegistry into this one by address.
125
+ * Uses asRefs to traverse structure level-by-level, avoiding full JS
126
+ * deserialization of composite values. Negative addresses (single-byte
127
+ * primitives) are universal and returned as-is.
128
+ *
129
+ * @param {CodecRegistry} source
130
+ * @param {number} address
131
+ * @returns {number} address of the value in this registry
132
+ */
133
+ copyFrom (source, address) {
134
+ if (address < 0) return address // universal: same footer in any registry
135
+ const value = source.decode(address)
136
+ const newCode = this.encode(value)
137
+ return this.addressOf(newCode) ?? this.append(newCode)
138
+ }
139
+
140
+ /**
141
+ * Appends a compound code by first splitting it into constituent subcodes
142
+ * (back-to-front, footer-determined widths) and appending each independently.
143
+ * Returns the address of the outermost subcode.
144
+ * @param {Uint8Array} code
145
+ * @returns {number}
146
+ */
147
+ append (code) {
148
+ const subcodes = []
149
+ let rest = code
150
+ while (rest.length) {
151
+ const codec = this.footerToCodec[rest.at(-1)]
152
+ const width = codec.getWidth(rest)
153
+ subcodes.unshift(rest.subarray(-width))
154
+ rest = rest.subarray(0, -width)
155
+ }
156
+ let last = -1
157
+ for (const sub of subcodes) {
158
+ const existing = this.addressOf(sub)
159
+ last = existing !== undefined ? existing : super.append(sub)
160
+ }
161
+ return last
162
+ }
163
+
164
+ // Internal append used by codecs (single chunk, no splitting)
165
+ #appendSubcode (code) {
166
+ if (this.addressOf(code) !== undefined) return this.addressOf(code)
167
+ return super.append(code)
168
+ }
169
+
170
+ #registerAll () {
171
+ for (const name in this.#codecs) {
172
+ const codec = this.#codecs[name]
173
+ codec.type = name
174
+ codec.baseFooter = this.footerToCodec.length
175
+ if (!codec.getWidth) {
176
+ codec.getWidth = code => {
177
+ const footer = code.at(-1)
178
+ const c = this.footerToCodec[footer]
179
+ if (!c?.partReaders?.length) return 1
180
+ let option = footer - c.baseFooter
181
+ let total = 1
182
+ for (let i = c.partReaders.length - 1; i >= 0; i--) {
183
+ const opts = c.partReaders[i]
184
+ const part = opts[option % opts.length](code.subarray(0, -total))
185
+ total += part.width
186
+ option = Math.floor(option / opts.length)
187
+ }
188
+ return total
189
+ }
190
+ }
191
+ const options = (codec.partReaders ?? []).reduce((n, opts) => n * opts.length, 1)
192
+ for (let i = 0; i < options; i++) this.footerToCodec.push(codec)
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A content-addressable trie mapping Uint8Array → address (number).
3
+ * Two Uint8Arrays with identical bytes will always resolve to the same entry.
4
+ * Lookup and insert are O(n) in the number of matching prefix bits, not O(n²).
5
+ */
6
+ export class ContentMap {
7
+ #offset
8
+ #code
9
+ #address
10
+ #branches = []
11
+
12
+ constructor (offset = 0, code, address = -1) {
13
+ this.#offset = offset
14
+ this.#code = code
15
+ this.#address = address
16
+ }
17
+
18
+ /**
19
+ * Returns the address stored for this code, or undefined if not found.
20
+ * @param {Uint8Array} code
21
+ * @returns {number|undefined}
22
+ */
23
+ get (code) {
24
+ if (this.#code === undefined || this.#address === -1) return undefined
25
+ const { match, matchingBits } = this.#compare(code)
26
+ if (match) return this.#address
27
+ return this.#branches[matchingBits]?.get(code)
28
+ }
29
+
30
+ /**
31
+ * Store a code → address mapping.
32
+ * @param {Uint8Array} code
33
+ * @param {number} address
34
+ */
35
+ set (code, address) {
36
+ if (this.#code === undefined || this.#address === -1) {
37
+ this.#code = code
38
+ this.#address = address
39
+ return
40
+ }
41
+ const { match, matchingBits, matchingBytes } = this.#compare(code)
42
+ if (match) throw new Error('code already exists in ContentMap')
43
+ if (this.#branches[matchingBits]) return this.#branches[matchingBits].set(code, address)
44
+ this.#branches[matchingBits] = new ContentMap(matchingBytes, code, address)
45
+ }
46
+
47
+ /**
48
+ * Clone the map, including only entries with address ≤ maxAddress.
49
+ * @param {number} maxAddress
50
+ * @returns {ContentMap}
51
+ */
52
+ clone (maxAddress) {
53
+ if (this.#address > maxAddress) throw new Error('clone address is before branch')
54
+ const copy = new ContentMap(this.#offset, this.#code, this.#address)
55
+ for (const i in this.#branches) {
56
+ const branch = this.#branches[i]
57
+ if (branch.#address <= maxAddress) copy.#branches[i] = branch.clone(maxAddress)
58
+ }
59
+ return copy
60
+ }
61
+
62
+ #compare (code) {
63
+ const matchingBits = countMatchingBits(code.subarray(this.#offset), this.#code.subarray(this.#offset))
64
+ const matchingBytes = this.#offset + Math.floor(matchingBits / 8)
65
+ const match = matchingBytes === code.length && matchingBytes === this.#code.length
66
+ return { match, matchingBits, matchingBytes }
67
+ }
68
+ }
69
+
70
+ function countMatchingBits (a, b) {
71
+ let bits = 0
72
+ for (let i = 0; i < a.length && i < b.length; i++) {
73
+ let j = 0
74
+ while ((a[i] >> j) !== (b[i] >> j)) j++
75
+ bits += 8 - j
76
+ if (j) break
77
+ }
78
+ return bits
79
+ }
@@ -0,0 +1,61 @@
1
+ # Stream — Design Notes (rebuild-1)
2
+
3
+ ## The goal
4
+
5
+ A personal signed stream of thoughts and information. After signing in, you make
6
+ changes to your data and anyone subscribed to your stream gets live updates. Viewers
7
+ see the same thing you see but with non-interactive controls. The stream is
8
+ cryptographically yours — signed with your key, verifiable by anyone.
9
+
10
+ ## What the previous implementation got right
11
+
12
+ - **Append-only, content-addressable storage.** Same value always lands at the same
13
+ address. This makes diffing trivial (compare addresses), deduplication free, and
14
+ sync simple (just send new bytes).
15
+
16
+ - **Negative addresses for primitives.** `undefined`, `null`, `false`, `true`, and
17
+ small integers (UINT7) are fully described by a single footer byte. Using
18
+ `-(footer + 1)` as their address means every value is addressable without
19
+ appending to the store. This arrived late in the previous version; it belongs
20
+ at the foundation.
21
+
22
+ - **Footer-based self-describing codec.** The last byte of any code identifies its
23
+ type. Multi-part values pack which storage option was chosen for each part into
24
+ the footer as a mixed-radix offset. Compact and self-contained.
25
+
26
+ - **Recaller.** Fine-grained reactive dependency tracking with path-level
27
+ granularity. Worth keeping essentially as-is.
28
+
29
+ - **The class hierarchy.** Addressifier → codec layer → reactive layer → signed
30
+ layer is a clean separation of concerns.
31
+
32
+ ## What I'd do differently
33
+
34
+ - **Hide Duple.** The balanced binary tree encoding of arrays and objects is an
35
+ implementation detail. Exposing `Duple` in the public API (users can encode and
36
+ decode `Duple` instances directly) leaks the internal representation. I'll keep
37
+ the binary tree structure but make it invisible outside the codec layer.
38
+
39
+ - **Codecs as separate objects, not one monolithic class.** The current
40
+ `TurtleCodecRegistry` inlines all codecs as private class fields. I'd rather
41
+ have each codec be a small, named, independently readable object registered into
42
+ a registry. The codec for dates shouldn't be physically entangled with the codec
43
+ for signatures.
44
+
45
+ - **The address space as a first-class concept.** Rather than `getCode` being a
46
+ method that happens to handle negative addresses, I'd make the address space
47
+ explicit: a thin object that knows how to resolve any address (negative or
48
+ positive) to bytes.
49
+
50
+ - **Stream as the primary concept, not storage.** The previous version built upward
51
+ from bytes. This version builds downward from the goal: a Stream is the thing,
52
+ and the layers beneath it exist to serve it.
53
+
54
+ ## Build order
55
+
56
+ 1. Addressifier — append-only byte store with content addressing
57
+ 2. Codecs — encode/decode for all value types, with the address space baked in
58
+ 3. Recaller — reactive dependency tracking
59
+ 4. Stream — reactive + signed, the primary user-facing class
60
+ 5. Sync — WebSocket-based append-only replication
61
+ 6. Rendering — hx template engine
@@ -0,0 +1,176 @@
1
+ import { Recaller } from './utils/Recaller.js'
2
+ import { Streamo, changedPaths } from './Streamo.js'
3
+
4
+ /**
5
+ * A Streamo whose values are commit records.
6
+ *
7
+ * Every write goes through a commit: checkout() → set() → commit(). This makes
8
+ * every connected device an equal author — writes are content-addressed,
9
+ * signed, and append-only. The server is just another peer; the keypair is the
10
+ * identity and the commit log is the source of truth.
11
+ *
12
+ * get() and set() are overridden to be transparent: callers use the same API
13
+ * as Streamo. get() reads from the last commit's dataAddress; set() creates a
14
+ * new commit automatically.
15
+ *
16
+ * The raw streamo (commit log) is what gets synced over WebSocket, S3, and
17
+ * archives. checkout() returns a working Streamo at any commit's dataAddress
18
+ * for read-only inspection or direct use with the explicit commit() API.
19
+ */
20
+ export class Repo extends Streamo {
21
+ /**
22
+ * The latest commit record, or null if nothing has been committed yet.
23
+ * Registers a reactive dependency on the commit log length.
24
+ * @returns {{ message: string, date: Date, dataAddress: number, parent: number|undefined }|null}
25
+ */
26
+ get lastCommit () {
27
+ this.recaller.reportKeyAccess(this, 'length')
28
+ // Use super.valueAddress (Streamo impl) to bypass our get() override and
29
+ // avoid a circular dependency: our get() calls lastCommit, lastCommit
30
+ // must not call our get().
31
+ const address = super.valueAddress
32
+ if (address < 0) return null
33
+ const value = this.decode(address)
34
+ if (!value || typeof value.message !== 'string' || !(value.date instanceof Date)) return null
35
+ return value
36
+ }
37
+
38
+ /**
39
+ * Decode the value at a path, reading from the last commit's dataAddress.
40
+ * Falls back to Streamo.get() if no commits exist yet.
41
+ *
42
+ * Registers reactive dependencies so watchers re-run when new commits land.
43
+ *
44
+ * @param {...(number|string)} args
45
+ * @returns {any}
46
+ */
47
+ get (...args) {
48
+ if (typeof args[0] === 'number') return super.get(...args)
49
+ const commit = this.lastCommit // registers 'length' dependency
50
+ if (!commit) return super.get(...args)
51
+ this.recaller.reportKeyAccess(this, JSON.stringify(args))
52
+ if (args.length === 0) return this.decode(commit.dataAddress)
53
+ let value = this.decode(commit.dataAddress)
54
+ for (const key of args) {
55
+ if (value == null) return undefined
56
+ value = value[key]
57
+ }
58
+ return value
59
+ }
60
+
61
+ /**
62
+ * Write a value by creating a new commit: checkout → set → commit.
63
+ *
64
+ * Signature: set([address,] ...path, value) — same as Streamo.set().
65
+ * Path-level reactive mutations are fired after commit so watchers only
66
+ * watching specific paths get precise notifications.
67
+ *
68
+ * @param {...(number|string|any)} args
69
+ * @returns {number} address of the new commit record
70
+ */
71
+ set (...args) {
72
+ if (typeof args[0] === 'number') return super.set(...args)
73
+ const prevDataAddress = this.lastCommit?.dataAddress
74
+ const working = this.checkout()
75
+ working.set(...args)
76
+ const result = this.commit(working)
77
+ const newDataAddress = this.lastCommit?.dataAddress
78
+ for (const changed of changedPaths(this, prevDataAddress, newDataAddress)) {
79
+ this.recaller.reportKeyMutation(this, JSON.stringify(changed))
80
+ }
81
+ return result
82
+ }
83
+
84
+ /**
85
+ * Like Streamo.getRefs() but reads from the last commit's dataAddress.
86
+ *
87
+ * @param {...string} path
88
+ * @returns {Object|number|undefined}
89
+ */
90
+ getRefs (...path) {
91
+ const commit = this.lastCommit
92
+ if (!commit) return super.getRefs(...path)
93
+ let address = commit.dataAddress
94
+ for (const key of path) {
95
+ const refs = this.asRefs(address)
96
+ if (typeof refs === 'number') return undefined
97
+ address = Array.isArray(refs) ? refs[+key] : refs[key]
98
+ if (address === undefined) return undefined
99
+ }
100
+ return this.asRefs(address)
101
+ }
102
+
103
+ /**
104
+ * Like Streamo.setRefs() but auto-commits via checkout → setRefs → commit.
105
+ *
106
+ * @param {...(string|number)} args ...path, address
107
+ * @returns {number} address of the new commit record
108
+ */
109
+ setRefs (...args) {
110
+ const prevDataAddress = this.lastCommit?.dataAddress
111
+ const working = this.checkout()
112
+ working.setRefs(...args)
113
+ const result = this.commit(working)
114
+ const newDataAddress = this.lastCommit?.dataAddress
115
+ for (const changed of changedPaths(this, prevDataAddress, newDataAddress)) {
116
+ this.recaller.reportKeyMutation(this, JSON.stringify(changed))
117
+ }
118
+ return result
119
+ }
120
+
121
+ /**
122
+ * Clone the repository at the last commit's data address.
123
+ * The returned Streamo's get() immediately returns the last committed value.
124
+ * Returns an empty Streamo if nothing has been committed yet.
125
+ * @returns {Streamo}
126
+ */
127
+ checkout () {
128
+ const commit = this.lastCommit
129
+ if (!commit) return new Streamo()
130
+ return this.clone(commit.dataAddress, new Recaller('checkout'))
131
+ }
132
+
133
+ /**
134
+ * The committed data from the last commit, decoded.
135
+ * Returns undefined if nothing has been committed yet.
136
+ * @returns {any}
137
+ */
138
+ get files () {
139
+ const commit = this.lastCommit
140
+ if (!commit) return undefined
141
+ return this.decode(commit.dataAddress)
142
+ }
143
+
144
+ /**
145
+ * Iterate commits from newest to oldest.
146
+ * @yields {{ message: string, date: Date, dataAddress: number, parent: number|undefined }}
147
+ */
148
+ * history () {
149
+ let commit = this.lastCommit
150
+ while (commit) {
151
+ yield commit
152
+ commit = commit.parent !== undefined ? this.decode(commit.parent) : null
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Copy the current value of workingStreamo into the repository and append a
158
+ * commit record referencing it by address.
159
+ *
160
+ * Uses super.valueAddress (skipping any trailing signatures) to find the
161
+ * correct parent commit address rather than byteLength - 1, which could
162
+ * point to a signature chunk when sign-in auto-signs after each commit.
163
+ *
164
+ * @param {Streamo} workingStreamo
165
+ * @param {string} [message='']
166
+ * @returns {number} address of the new commit record
167
+ */
168
+ commit (workingStreamo, message = '') {
169
+ if (workingStreamo.byteLength === 0) throw new Error('nothing to commit')
170
+ const parentAddr = super.valueAddress
171
+ const parent = parentAddr >= 0 ? parentAddr : undefined
172
+ const dataAddress = this.copyFrom(workingStreamo, workingStreamo.byteLength - 1)
173
+ const code = this.encode({ message, date: new Date(), dataAddress, parent })
174
+ return this.append(code)
175
+ }
176
+ }
@@ -0,0 +1,82 @@
1
+ import { describe } from './utils/testing.js'
2
+ import { Repo } from './Repo.js'
3
+
4
+ describe(import.meta.url, ({ test }) => {
5
+ test('commit stores message, date, and a reference to the data', ({ assert }) => {
6
+ const repo = new Repo()
7
+ const working = repo.checkout()
8
+ working.set({ a: 1 })
9
+ const commitAddr = repo.commit(working, 'first commit')
10
+ const commit = repo.decode(commitAddr)
11
+ assert.equal(commit.message, 'first commit')
12
+ assert.ok(commit.date instanceof Date)
13
+ assert.equal(typeof commit.dataAddress, 'number')
14
+ assert.deepEqual(repo.decode(commit.dataAddress), { a: 1 })
15
+ })
16
+
17
+ test('checkout starts with last committed value', ({ assert }) => {
18
+ const repo = new Repo()
19
+ const working = repo.checkout()
20
+ working.set({ a: 1 })
21
+ repo.commit(working, 'first')
22
+ const working2 = repo.checkout()
23
+ assert.deepEqual(working2.get(), { a: 1 })
24
+ })
25
+
26
+ test('checkout of empty repo returns empty stream', ({ assert }) => {
27
+ const repo = new Repo()
28
+ const working = repo.checkout()
29
+ assert.equal(working.byteLength, 0)
30
+ })
31
+
32
+ test('working stream modifications do not affect the repository', ({ assert }) => {
33
+ const repo = new Repo()
34
+ const working = repo.checkout()
35
+ working.set({ v: 1 })
36
+ repo.commit(working, 'first')
37
+ const working2 = repo.checkout()
38
+ working2.set({ v: 99 })
39
+ // repo still has v:1 as last committed value
40
+ assert.deepEqual(repo.decode(repo.lastCommit.dataAddress), { v: 1 })
41
+ })
42
+
43
+ test('multiple commits produce a linked history via parent', ({ assert }) => {
44
+ const repo = new Repo()
45
+ const working = repo.checkout()
46
+ working.set({ v: 1 })
47
+ repo.commit(working, 'first')
48
+ working.set('v', 2)
49
+ repo.commit(working, 'second')
50
+ const c2 = repo.lastCommit
51
+ assert.equal(c2.message, 'second')
52
+ assert.deepEqual(repo.decode(c2.dataAddress), { v: 2 })
53
+ const c1 = repo.decode(c2.parent)
54
+ assert.equal(c1.message, 'first')
55
+ assert.deepEqual(repo.decode(c1.dataAddress), { v: 1 })
56
+ })
57
+
58
+ test('first commit has no parent', ({ assert }) => {
59
+ const repo = new Repo()
60
+ const working = repo.checkout()
61
+ working.set({ x: 1 })
62
+ repo.commit(working, 'root')
63
+ assert.equal(repo.lastCommit.parent, undefined)
64
+ })
65
+
66
+ test('unchanged data reuses the same address across commits', ({ assert }) => {
67
+ const repo = new Repo()
68
+ const working = repo.checkout()
69
+ working.set({ x: 42 })
70
+ repo.commit(working, 'first')
71
+ repo.commit(working, 'second')
72
+ const c2 = repo.lastCommit
73
+ const c1 = repo.decode(c2.parent)
74
+ assert.equal(c1.dataAddress, c2.dataAddress, 'same data reuses the same address')
75
+ })
76
+
77
+ test('throws when working stream is empty', ({ assert }) => {
78
+ const repo = new Repo()
79
+ const working = repo.checkout()
80
+ assert.throws(() => repo.commit(working, 'nothing here'))
81
+ })
82
+ })
@@ -0,0 +1,91 @@
1
+ import { Streamo } from './Streamo.js'
2
+ import { Repo } from './Repo.js'
3
+
4
+ /**
5
+ * Manages a collection of Repos keyed by hex-encoded public key.
6
+ *
7
+ * Accepts an optional factory function that is called whenever a new repository
8
+ * is opened. The factory receives the publicKeyHex and should return a
9
+ * (optionally async) Repo with whatever persistence or sync wired up.
10
+ *
11
+ * If no factory is provided, plain in-memory Repos are created.
12
+ *
13
+ * Examples:
14
+ *
15
+ * // plain in-memory
16
+ * new RepoRegistry()
17
+ *
18
+ * // archive-backed
19
+ * new RepoRegistry(async key => {
20
+ * const repo = new Repo()
21
+ * await archiveSync(repo, dataDir, key)
22
+ * return repo
23
+ * })
24
+ *
25
+ * // S3-backed
26
+ * new RepoRegistry(async key => {
27
+ * const repo = new Repo()
28
+ * await s3Sync(repo, key, s3Config)
29
+ * return repo
30
+ * })
31
+ */
32
+ export class RepoRegistry {
33
+ #streams = new Map()
34
+ #factory
35
+ #openCallbacks = new Set()
36
+
37
+ /** @param {(publicKeyHex: string) => Repo | Promise<Repo>} [factory] */
38
+ constructor (factory = () => new Repo()) {
39
+ this.#factory = factory
40
+ }
41
+
42
+ /**
43
+ * Return the Repo for `publicKeyHex`, creating it via the factory if
44
+ * this is the first call for that key.
45
+ *
46
+ * The repository is registered immediately (before the factory resolves) so
47
+ * concurrent open() calls always return the same instance.
48
+ *
49
+ * @param {string} publicKeyHex
50
+ * @returns {Promise<Repo>}
51
+ */
52
+ async open (publicKeyHex) {
53
+ if (this.#streams.has(publicKeyHex)) return this.#streams.get(publicKeyHex)
54
+ let resolve
55
+ const placeholder = new Promise(r => { resolve = r })
56
+ this.#streams.set(publicKeyHex, placeholder)
57
+ const stream = await this.#factory(publicKeyHex)
58
+ this.#streams.set(publicKeyHex, stream)
59
+ resolve(stream)
60
+ for (const cb of this.#openCallbacks) cb(publicKeyHex, stream)
61
+ return stream
62
+ }
63
+
64
+ /** Register a callback invoked whenever a new repo is fully opened. */
65
+ onOpen (cb) { this.#openCallbacks.add(cb) }
66
+
67
+ /** Remove a previously registered onOpen callback. */
68
+ offOpen (cb) { this.#openCallbacks.delete(cb) }
69
+
70
+ /**
71
+ * Return an already-open Repo, or undefined if not opened yet.
72
+ * @param {string} publicKeyHex
73
+ * @returns {Repo|undefined}
74
+ */
75
+ get (publicKeyHex) {
76
+ const entry = this.#streams.get(publicKeyHex)
77
+ return entry instanceof Streamo ? entry : undefined
78
+ }
79
+
80
+ /** Number of currently open (or opening) repos. */
81
+ get size () { return this.#streams.size }
82
+
83
+ /** Iterate over [publicKeyHex, Repo] pairs (only fully-opened). */
84
+ [Symbol.iterator] () {
85
+ return (function * (map) {
86
+ for (const [k, v] of map) {
87
+ if (v instanceof Streamo) yield [k, v]
88
+ }
89
+ })(this.#streams)
90
+ }
91
+ }