braid-blob 0.0.68 → 0.0.70

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 CHANGED
@@ -46,7 +46,7 @@ node server-demo.js
46
46
 
47
47
  Now open up http://localhost:8888 in your browser, to see the client. Open two windows. You can drag and drop images between them, and they will always stay synchronized.
48
48
 
49
- <video src="https://github.com/user-attachments/assets/66ba0004-138c-4faa-a1d5-cb2cd06d3525" controls width="600"></video>
49
+ <video src="https://github.com/user-attachments/assets/9416e06b-143a-4b3a-a840-b6484f2571b1" controls width="600"></video>
50
50
 
51
51
  ## Network API
52
52
 
@@ -56,7 +56,8 @@ Braid-blob speaks [Braid-HTTP](https://github.com/braid-org/braid-spec), an exte
56
56
 
57
57
  | Header | Description |
58
58
  |--------|-------------|
59
- | `Version` | Unique identifier for this version of the blob (e.g., `"alice-42"`) |
59
+ | `Version` | Unique identifier for this version of the blob (e.g., `"1768467700000"`) |
60
+ | `Version-Type` | How to interpret the structure of version strings (e.g., [`relative-wallclock`](https://braid.org/protocol/version-types/relative-wallclock)); see [Version-Type spec](https://github.com/braid-org/braid-spec/blob/master/draft-toomim-httpbis-versions-03.txt) |
60
61
  | `Parents` | The previous version |
61
62
  | `Merge-Type` | How conflicts resolve consistently (*e.g.* `aww` for [arbitrary-writer-wins](https://braid.org/protocol/merge-types/aww)) |
62
63
  | `Subscribe` | In GET, subscribes client to all future changes |
@@ -73,7 +74,8 @@ Response:
73
74
 
74
75
  ```http
75
76
  HTTP/1.1 200 OK
76
- Version: "alice-1"
77
+ Version: "1768467700000"
78
+ Version-Type: relative-wallclock
77
79
  Content-Type: image/png
78
80
  Merge-Type: aww
79
81
  Accept-Subscribe: true
@@ -98,10 +100,12 @@ Response (keeps connection open, streams updates):
98
100
  ```http
99
101
  HTTP/1.1 209 Multiresponse
100
102
  Subscribe: true
101
- Current-Version: "alice-1"
103
+ Current-Version: "1768467700000"
104
+ Version-Type: relative-wallclock
102
105
 
103
106
  HTTP 200 OK
104
- Version: "alice-1"
107
+ Version: "1768467700000"
108
+ Version-Type: relative-wallclock
105
109
  Content-Type: image/png
106
110
  Merge-Type: aww
107
111
  Content-Length: 12345
@@ -109,7 +113,8 @@ Content-Length: 12345
109
113
  <binary data>
110
114
 
111
115
  HTTP 200 OK
112
- Version: "bob-2"
116
+ Version: "1768467701000"
117
+ Version-Type: relative-wallclock
113
118
  Content-Type: image/png
114
119
  Merge-Type: aww
115
120
  Content-Length: 23456
@@ -124,7 +129,8 @@ If the blob doesn't exist yet, `Current-Version` will be blank and no initial up
124
129
 
125
130
  ```http
126
131
  PUT /blob.png HTTP/1.1
127
- Version: "carol-3"
132
+ Version: "1768467702000"
133
+ Version-Type: relative-wallclock
128
134
  Content-Type: image/png
129
135
  Merge-Type: aww
130
136
  Content-Length: 34567
@@ -136,7 +142,8 @@ Response:
136
142
 
137
143
  ```http
138
144
  HTTP/1.1 200 OK
139
- Current-Version: "carol-3"
145
+ Current-Version: "1768467702000"
146
+ Version-Type: relative-wallclock
140
147
  ```
141
148
 
142
149
  If the sent version is older or eclipsed by the server's current version, the returned `Current-Version` will be the server's version (not the one you sent).
@@ -157,13 +164,19 @@ HTTP/1.1 200 OK
157
164
 
158
165
  Returns `200 OK` even if the blob didn't exist.
159
166
 
160
- ### Understanding versions
167
+ ### Understanding versions and conflict resolution
161
168
 
162
- Versions look like `"alice-42"` where:
163
- - `alice` is a peer ID (identifies who made the change)
164
- - `42` is a sequence number (generally milliseconds past the epoch, or one plus the current number if it is past the current time)
169
+ Braid-blob uses two complementary mechanisms for distributed consistency:
165
170
 
166
- Conflicts resolve using ["arbitrary-writer-wins" (AWW)](https://braid.org/protocol/merge-types/aww): the version with the highest sequence number wins. If sequences match, the peer ID string is compared lexicographically.
171
+ **Version-Type: [`relative-wallclock`](https://braid.org/protocol/version-types/relative-wallclock)** defines the format and interpretation of version identifiers:
172
+ - Versions are millisecond timestamps (e.g., `"1768467700000"`)
173
+ - If the current time is behind the latest known version, a small random number (1-1000) is added to the current version, providing entropy when multiple peers write simultaneously
174
+ - Versions are compared numerically—larger timestamps are newer
175
+
176
+ **Merge-Type: [`aww`](https://braid.org/protocol/merge-types/aww)** (arbitrary-writer-wins) defines how conflicts are resolved:
177
+ - When two peers make concurrent changes, the version with the higher timestamp wins
178
+ - All peers deterministically converge to the same state without coordination
179
+ - This provides "last-writer-wins" semantics under normal clock conditions
167
180
 
168
181
 
169
182
 
@@ -192,7 +205,7 @@ var {body, version, content_type} = await braid_blob.get(new URL('https://foo.ba
192
205
  // Get a specific version of a remote blob:
193
206
  var {body} = await braid_blob.get(
194
207
  new URL('https://foo.bar/baz'),
195
- {version: ['5zb2sjdstmk-1768093765048']}
208
+ {version: ['1768467700000']}
196
209
  )
197
210
 
198
211
  // To subscribe to a remote blob, without storing updates locally:
@@ -245,8 +258,8 @@ Retrieves a blob from local storage or a remote URL.
245
258
  Parameters:
246
259
  - `key` - The local blob (if string) or remote URL (if [URL object](https://nodejs.org/api/url.html#class-url)) to read from
247
260
  - `params` - Optional configuration object
248
- - `version` - Retrieve a specific version instead of the latest (e.g., `['abc-123']`)
249
- - `parents` - When subscribing, only receive updates newer than this version (e.g., `['abc-123']`)
261
+ - `version` - Retrieve a specific version instead of the latest (e.g., `['1768467700000']`)
262
+ - `parents` - When subscribing, only receive updates newer than this version (e.g., `['1768467700000']`)
250
263
  - `subscribe` - Callback `(update) => {}` called with each update; `update` has `{body, version, content_type}`
251
264
  - `head` - If `true`, returns only metadata (`{version, content_type}`) without the body—useful for checking if a blob exists or getting its current version
252
265
  - `content_type` - Expected content type (sent as Accept header for remote URLs)
@@ -262,7 +275,7 @@ Parameters:
262
275
  - `key` - The local blob (if string) or remote URL (if [URL object](https://nodejs.org/api/url.html#class-url)) to write to
263
276
  - `body` - The data to store (Buffer, ArrayBuffer, or Uint8Array)
264
277
  - `params` - Optional configuration object
265
- - `version` - Specify a version ID for this write (e.g., `['my-version-1']`); if omitted, one is generated automatically
278
+ - `version` - Specify a version ID for this write (e.g., `['1768467700000']`); if omitted, one is generated automatically
266
279
  - `content_type` - MIME type of the blob (e.g., `'image/png'`, `'application/json'`)
267
280
  - `signal` - AbortSignal to cancel the request
268
281
 
package/client.js CHANGED
@@ -51,9 +51,7 @@ function braid_blob_client(url, params = {}) {
51
51
 
52
52
  return {
53
53
  update: async (body, content_type) => {
54
- var seq = max_seq('' + Date.now(),
55
- increment_seq(get_event_seq(current_version)))
56
- current_version = `${peer}-${seq}`
54
+ current_version = create_event(current_version)
57
55
 
58
56
  params.on_update?.(body, content_type, current_version)
59
57
 
@@ -80,41 +78,70 @@ function braid_blob_client(url, params = {}) {
80
78
  return 0
81
79
  }
82
80
 
81
+ function create_event(current_event, max_entropy = 1000) {
82
+ var new_event = '' + Date.now()
83
+
84
+ var current_seq = get_event_seq(current_event)
85
+ if (compare_seqs(new_event, current_seq) > 0) return new_event
86
+
87
+ // Find smallest base-10 integer where compare_seqs(int, current_seq) >= 0
88
+ var base = seq_to_int(current_seq)
89
+ return '' + (base + 1 + Math.floor(Math.random() * max_entropy))
90
+ }
91
+
83
92
  function get_event_seq(e) {
84
93
  if (!e) return ''
85
94
 
86
95
  for (let i = e.length - 1; i >= 0; i--)
87
- if (e[i] === '-') return e.slice(i + 1)
96
+ if (e[i] === '-') return i == 0 ? e : e.slice(i + 1)
88
97
  return e
89
98
  }
90
99
 
91
- function increment_seq(s) {
92
- if (!s) return '1'
93
-
94
- let last = s[s.length - 1]
95
- let rest = s.slice(0, -1)
96
-
97
- if (last >= '0' && last <= '8')
98
- return rest + String.fromCharCode(last.charCodeAt(0) + 1)
99
- else
100
- return increment_seq(rest) + '0'
101
- }
102
-
103
- function max_seq(a, b) {
100
+ function compare_seqs(a, b) {
104
101
  if (!a) a = ''
105
102
  if (!b) b = ''
106
103
 
107
- if (compare_seqs(a, b) > 0) return a
108
- return b
109
- }
104
+ var a_neg = a[0] === '-'
105
+ var b_neg = b[0] === '-'
106
+ if (a_neg !== b_neg) return a_neg ? -1 : 1
110
107
 
111
- function compare_seqs(a, b) {
112
- if (!a) a = ''
113
- if (!b) b = ''
108
+ // Both negative: compare magnitudes (reversed)
109
+ if (a_neg) {
110
+ var swap = a.slice(1); a = b.slice(1); b = swap
111
+ }
114
112
 
115
113
  if (a.length !== b.length) return a.length - b.length
116
114
  if (a < b) return -1
117
115
  if (a > b) return 1
118
116
  return 0
119
117
  }
118
+
119
+ // Smallest base-10 integer n where compare_seqs(String(n), s) >= 0
120
+ function seq_to_int(s) {
121
+ if (!s || s[0] === '-') return 0
122
+
123
+ var len = s.length
124
+ var min_of_len = Math.pow(10, len - 1) // e.g., len=3 -> 100
125
+ var max_of_len = Math.pow(10, len) - 1 // e.g., len=3 -> 999
126
+
127
+ if (s < String(min_of_len)) return min_of_len
128
+ if (s > String(max_of_len)) return max_of_len + 1
129
+
130
+ // s is in the base-10 range for this length
131
+ // scan for first non-digit > '9', increment prefix and pad zeros
132
+ var n = 0
133
+ for (var i = 0; i < len; i++) {
134
+ var c = s.charCodeAt(i)
135
+ if (c >= 48 && c <= 57) {
136
+ n = n * 10 + (c - 48)
137
+ } else if (c > 57) {
138
+ // non-digit > '9': increment prefix, pad rest with zeros
139
+ return (n + 1) * Math.pow(10, len - i)
140
+ } else {
141
+ // non-digit < '0': just pad rest with zeros
142
+ return n * Math.pow(10, len - i)
143
+ }
144
+ }
145
+ return n
146
+ }
120
147
  }
package/index.js CHANGED
@@ -152,6 +152,7 @@ function create_braid_blob() {
152
152
  header_cb: (result) => {
153
153
  res.setHeader((req.subscribe ? "Current-" : "") +
154
154
  "Version", version_to_header(result.version))
155
+ res.setHeader("Version-Type", "relative-wallclock")
155
156
  if (result.content_type)
156
157
  res.setHeader('Content-Type', result.content_type)
157
158
  },
@@ -166,6 +167,7 @@ function create_braid_blob() {
166
167
  delete update.content_type
167
168
  }
168
169
  update['Merge-Type'] = 'aww'
170
+ update['Version-Type'] = 'relative-wallclock'
169
171
  res.sendUpdate(update)
170
172
  } : null
171
173
  })
@@ -204,6 +206,7 @@ function create_braid_blob() {
204
206
  peer: req.peer
205
207
  })
206
208
  res.setHeader("Current-Version", version_to_header(event != null ? [event] : []))
209
+ res.setHeader("Version-Type", "relative-wallclock")
207
210
  res.end('')
208
211
  } else if (req.method === 'DELETE') {
209
212
  await braid_blob.delete(params.key, {
@@ -234,6 +237,9 @@ function create_braid_blob() {
234
237
  if (params.content_type)
235
238
  fetch_params.headers = { ...fetch_params.headers,
236
239
  'Accept': params.content_type }
240
+ if (params.version || params.parents)
241
+ fetch_params.headers = { ...fetch_params.headers,
242
+ 'Version-Type': 'relative-wallclock' }
237
243
 
238
244
  var res = await braid_fetch(key.href, fetch_params)
239
245
 
@@ -345,6 +351,9 @@ function create_braid_blob() {
345
351
  if (params.content_type)
346
352
  fetch_params.headers = { ...fetch_params.headers,
347
353
  'Content-Type': params.content_type }
354
+ if (params.version)
355
+ fetch_params.headers = { ...fetch_params.headers,
356
+ 'Version-Type': 'relative-wallclock' }
348
357
 
349
358
  return await braid_fetch(key.href, fetch_params)
350
359
  }
@@ -358,8 +367,7 @@ function create_braid_blob() {
358
367
 
359
368
  var their_e = params.version ? params.version[0] :
360
369
  // we'll give them a event id in this case
361
- `${braid_blob.peer}-${max_seq('' + Date.now(),
362
- meta.event ? increment_seq(get_event_seq(meta.event)) : '')}`
370
+ create_event(meta.event)
363
371
 
364
372
  if (compare_events(their_e, meta.event) > 0) {
365
373
  meta.event = their_e
@@ -591,37 +599,37 @@ function create_braid_blob() {
591
599
  return 0
592
600
  }
593
601
 
602
+ function create_event(current_event, max_entropy = 1000) {
603
+ var new_event = '' + Date.now()
604
+
605
+ var current_seq = get_event_seq(current_event)
606
+ if (compare_seqs(new_event, current_seq) > 0) return new_event
607
+
608
+ // Find smallest base-10 integer where compare_seqs(int, current_seq) >= 0
609
+ var base = seq_to_int(current_seq)
610
+ return '' + (base + 1 + Math.floor(Math.random() * max_entropy))
611
+ }
612
+
594
613
  function get_event_seq(e) {
595
614
  if (!e) return ''
596
615
 
597
616
  for (let i = e.length - 1; i >= 0; i--)
598
- if (e[i] === '-') return e.slice(i + 1)
617
+ if (e[i] === '-') return i == 0 ? e : e.slice(i + 1)
599
618
  return e
600
619
  }
601
620
 
602
- function increment_seq(s) {
603
- if (!s) return '1'
604
-
605
- let last = s[s.length - 1]
606
- let rest = s.slice(0, -1)
607
-
608
- if (last >= '0' && last <= '8')
609
- return rest + String.fromCharCode(last.charCodeAt(0) + 1)
610
- else
611
- return increment_seq(rest) + '0'
612
- }
613
-
614
- function max_seq(a, b) {
621
+ function compare_seqs(a, b) {
615
622
  if (!a) a = ''
616
623
  if (!b) b = ''
617
624
 
618
- if (compare_seqs(a, b) > 0) return a
619
- return b
620
- }
625
+ var a_neg = a[0] === '-'
626
+ var b_neg = b[0] === '-'
627
+ if (a_neg !== b_neg) return a_neg ? -1 : 1
621
628
 
622
- function compare_seqs(a, b) {
623
- if (!a) a = ''
624
- if (!b) b = ''
629
+ // Both negative: compare magnitudes (reversed)
630
+ if (a_neg) {
631
+ var swap = a.slice(1); a = b.slice(1); b = swap
632
+ }
625
633
 
626
634
  if (a.length !== b.length) return a.length - b.length
627
635
  if (a < b) return -1
@@ -629,6 +637,35 @@ function create_braid_blob() {
629
637
  return 0
630
638
  }
631
639
 
640
+ // Smallest base-10 integer n where compare_seqs(String(n), s) >= 0
641
+ function seq_to_int(s) {
642
+ if (!s || s[0] === '-') return 0
643
+
644
+ var len = s.length
645
+ var min_of_len = Math.pow(10, len - 1) // e.g., len=3 -> 100
646
+ var max_of_len = Math.pow(10, len) - 1 // e.g., len=3 -> 999
647
+
648
+ if (s < String(min_of_len)) return min_of_len
649
+ if (s > String(max_of_len)) return max_of_len + 1
650
+
651
+ // s is in the base-10 range for this length
652
+ // scan for first non-digit > '9', increment prefix and pad zeros
653
+ var n = 0
654
+ for (var i = 0; i < len; i++) {
655
+ var c = s.charCodeAt(i)
656
+ if (c >= 48 && c <= 57) {
657
+ n = n * 10 + (c - 48)
658
+ } else if (c > 57) {
659
+ // non-digit > '9': increment prefix, pad rest with zeros
660
+ return (n + 1) * Math.pow(10, len - i)
661
+ } else {
662
+ // non-digit < '0': just pad rest with zeros
663
+ return n * Math.pow(10, len - i)
664
+ }
665
+ }
666
+ return n
667
+ }
668
+
632
669
  function ascii_ify(s) {
633
670
  return s.replace(/[^\x20-\x7E]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
634
671
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-blob",
3
- "version": "0.0.68",
3
+ "version": "0.0.70",
4
4
  "description": "Library for collaborative blobs over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-blob",
@@ -0,0 +1,102 @@
1
+ # Relative-Wallclock Version Type
2
+
3
+ This document describes the **relative-wallclock** version type, which specifies how version identifiers are formatted and interpreted for resources using wallclock-based timestamps.
4
+
5
+ ## Overview
6
+
7
+ The relative-wallclock version type uses millisecond timestamps as version identifiers. These timestamps generally correspond to wall-clock time (milliseconds since the Unix epoch), but can advance beyond wall-clock time when necessary to maintain monotonicity.
8
+
9
+ ## HTTP Header
10
+
11
+ The relative-wallclock version type is indicated using the `Version-Type` header:
12
+
13
+ ```
14
+ Version-Type: relative-wallclock
15
+ ```
16
+
17
+ ## Version Format
18
+
19
+ Versions are numeric strings representing milliseconds since the Unix epoch:
20
+
21
+ ```
22
+ "1768467700000"
23
+ ```
24
+
25
+ Unlike some versioning schemes that include an agent/peer identifier (e.g., `"alice-1736625600000"`), relative-wallclock versions contain only the timestamp.
26
+
27
+ ## Version Generation
28
+
29
+ When creating a new version, the timestamp is calculated as:
30
+
31
+ ```
32
+ new_version = max(current_time_ms, current_version + random(1, 1000))
33
+ ```
34
+
35
+ This ensures:
36
+
37
+ 1. **Monotonicity**: Versions always increase, even if the clock drifts backward
38
+ 2. **Entropy**: A small random number (between 1 and 1000) is added when the clock is behind, reducing collision probability when multiple peers write simultaneously
39
+ 3. **Approximate wall-clock correspondence**: Under normal conditions, versions reflect actual time
40
+
41
+ ## Comparison Procedure
42
+
43
+ When comparing two versions `a` and `b`:
44
+
45
+ 1. **Compare as integers**: Parse the version strings as integers and compare numerically
46
+ 2. **Larger wins**: The version with the larger numeric value is considered newer
47
+
48
+ ```
49
+ "1768467701000" > "1768467700000" (numerically larger)
50
+ ```
51
+
52
+ Note: Because versions are numeric strings of potentially different lengths, comparison should be done numerically, not lexicographically. However, in practice, millisecond timestamps from the same era will have the same number of digits.
53
+
54
+ ## Relationship to Merge Types
55
+
56
+ The relative-wallclock version type specifies only the *format and interpretation* of version identifiers. It does not specify how conflicts are resolved when concurrent versions exist.
57
+
58
+ Conflict resolution is handled by a **Merge Type** (specified via the `Merge-Type` header). For example, the [Arbitrary-Writer-Wins (AWW)](https://braid.org/protocol/merge-types/aww) merge type uses version comparison to deterministically select a winner.
59
+
60
+ When used together:
61
+ - `Version-Type: relative-wallclock` defines how to interpret and compare version strings
62
+ - `Merge-Type: aww` defines that the higher version wins in a conflict
63
+
64
+ ## Example
65
+
66
+ ```http
67
+ PUT /blob.png HTTP/1.1
68
+ Version: "1768467702000"
69
+ Version-Type: relative-wallclock
70
+ Content-Type: image/png
71
+ Merge-Type: aww
72
+ Content-Length: 34567
73
+
74
+ <binary data>
75
+ ```
76
+
77
+ Response:
78
+
79
+ ```http
80
+ HTTP/1.1 200 OK
81
+ Current-Version: "1768467702000"
82
+ Version-Type: relative-wallclock
83
+ ```
84
+
85
+ ## Properties
86
+
87
+ - **Simple**: Version identifiers are just numbers
88
+ - **Sortable**: Versions have a natural total ordering
89
+ - **Distributed**: No coordination required between peers to generate versions
90
+ - **Approximate causality**: Generally reflects wall-clock time, providing rough temporal ordering
91
+ - **Collision-resistant**: Random entropy reduces concurrent write collisions
92
+
93
+ ## Security Considerations
94
+
95
+ - **Clock manipulation**: A malicious peer could set their clock far in the future to generate versions that always win. Systems should consider rate-limiting or rejecting versions too far in the future.
96
+ - **No authentication**: Version identifiers do not encode who created them. Authentication should be handled at a different layer.
97
+
98
+ ## References
99
+
100
+ - [HTTP Resource Versioning](https://github.com/braid-org/braid-spec/blob/master/draft-toomim-httpbis-versions-03.txt)
101
+ - [Merge Types](https://github.com/braid-org/braid-spec/blob/master/draft-toomim-httpbis-merge-types-00.txt)
102
+ - [Arbitrary-Writer-Wins Merge Type](https://braid.org/protocol/merge-types/aww)