@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.
- package/.claude/settings.local.json +10 -0
- package/LICENSE +661 -0
- package/README.md +194 -0
- package/ROADMAP.md +111 -0
- package/bin/streamo.js +238 -0
- package/jsconfig.json +9 -0
- package/package.json +26 -0
- package/public/apps/chat/index.html +61 -0
- package/public/apps/chat/main.js +144 -0
- package/public/apps/styles/proto.css +71 -0
- package/public/index.html +109 -0
- package/public/streamo/Addressifier.js +212 -0
- package/public/streamo/CodecRegistry.js +195 -0
- package/public/streamo/ContentMap.js +79 -0
- package/public/streamo/DESIGN.md +61 -0
- package/public/streamo/Repo.js +176 -0
- package/public/streamo/Repo.test.js +82 -0
- package/public/streamo/RepoRegistry.js +91 -0
- package/public/streamo/RepoRegistry.test.js +87 -0
- package/public/streamo/Signature.js +15 -0
- package/public/streamo/Signer.js +91 -0
- package/public/streamo/Streamo.js +392 -0
- package/public/streamo/Streamo.test.js +205 -0
- package/public/streamo/archiveSync.js +62 -0
- package/public/streamo/chat-cli.js +122 -0
- package/public/streamo/chat-server.js +60 -0
- package/public/streamo/codecs.js +400 -0
- package/public/streamo/fileSync.js +238 -0
- package/public/streamo/h.js +202 -0
- package/public/streamo/h.mount.test.js +67 -0
- package/public/streamo/h.test.js +121 -0
- package/public/streamo/mount.js +248 -0
- package/public/streamo/originSync.js +60 -0
- package/public/streamo/outletSync.js +105 -0
- package/public/streamo/registrySync.js +333 -0
- package/public/streamo/registrySync.test.js +373 -0
- package/public/streamo/s3Sync.js +99 -0
- package/public/streamo/stateFileSync.js +17 -0
- package/public/streamo/sync.test.js +98 -0
- package/public/streamo/utils/NestedSet.js +41 -0
- package/public/streamo/utils/Recaller.js +77 -0
- package/public/streamo/utils/mockDOM.js +113 -0
- package/public/streamo/utils/nextTick.js +22 -0
- package/public/streamo/utils/noble-secp256k1.js +602 -0
- package/public/streamo/utils/testing.js +90 -0
- package/public/streamo/utils.js +57 -0
- package/public/streamo/webSync.js +118 -0
- package/scripts/serve.js +15 -0
- 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
|
+
}
|