braid-blob 0.0.31 → 0.0.33

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/AI-README.md CHANGED
@@ -103,7 +103,7 @@ COMPARISON_RULES:
103
103
  1. Compare numeric length of timestamp
104
104
  2. If equal, lexicographic compare timestamp
105
105
  3. If equal, lexicographic compare full version string
106
- MERGE_TYPE: last-write-wins (lww)
106
+ MERGE_TYPE: arbitrary-writer-wins (aww)
107
107
  ```
108
108
 
109
109
  ## METADATA_SCHEMA
@@ -145,7 +145,7 @@ SIDE_EFFECTS:
145
145
  VERSION_LOGIC:
146
146
  - If options.version provided: use options.version[0]
147
147
  - Else: generate "{peer}-{max(Date.now(), last_version_seq+1)}"
148
- - Only write if new version > existing version (lww)
148
+ - Only write if new version > existing version (aww)
149
149
  ```
150
150
 
151
151
  ### get(key, options)
@@ -320,7 +320,7 @@ HTTP_HEADERS:
320
320
  Current-Version: "v1" # for subscribed GET
321
321
  Editable: true
322
322
  Accept-Subscribe: true
323
- Merge-Type: lww
323
+ Merge-Type: aww
324
324
  Content-Type: mime/type
325
325
 
326
326
  STATUS_CODES:
@@ -332,7 +332,7 @@ STATUS_CODES:
332
332
 
333
333
  BRAID_UPDATE_FORMAT:
334
334
  Version: "v1"\r\n
335
- Merge-Type: lww\r\n
335
+ Merge-Type: aww\r\n
336
336
  Content-Length: N\r\n
337
337
  \r\n
338
338
  {body}
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # braid-blob
2
2
 
3
- A simple, self-contained library for synchronizing binary blobs (files, images, etc.) over HTTP using [Braid-HTTP](https://braid.org). It provides real-time synchronization with last-write-wins (LWW) conflict resolution and persistent storage.
3
+ A simple, self-contained library for synchronizing binary blobs (files, images, etc.) over HTTP using [Braid-HTTP](https://braid.org). It provides real-time synchronization with arbitrary-writer-wins (AWW) conflict resolution and persistent storage.
4
4
 
5
5
  ## Quick Start
6
6
 
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  var {http_server: braidify, fetch: braid_fetch} = require('braid-http'),
2
2
  {url_file_db} = require('url-file-db'),
3
+ fs = require('fs'),
3
4
  path = require('path')
4
5
 
5
6
  function create_braid_blob() {
@@ -7,9 +8,10 @@ function create_braid_blob() {
7
8
  db_folder: './braid-blob-db',
8
9
  meta_folder: './braid-blob-meta',
9
10
  cache: {},
11
+ meta_cache: {},
10
12
  key_to_subs: {},
11
13
  peer: null, // will be auto-generated if not set by the user
12
- db: null // url-file-db instance with integrated meta storage
14
+ db: null // url-file-db instance
13
15
  }
14
16
 
15
17
  braid_blob.init = async () => {
@@ -19,10 +21,17 @@ function create_braid_blob() {
19
21
  await braid_blob.init()
20
22
 
21
23
  async function real_init() {
22
- // Create url-file-db instance with integrated meta storage
24
+ // Ensure our meta folder exists
25
+ await fs.promises.mkdir(braid_blob.meta_folder, { recursive: true })
26
+
27
+ // Create a fake meta folder for url-file-db (we manage our own meta)
28
+ var fake_meta_folder = braid_blob.meta_folder + '-fake'
29
+ await fs.promises.mkdir(fake_meta_folder, { recursive: true })
30
+
31
+ // Create url-file-db instance (with fake meta folder - we manage our own)
23
32
  braid_blob.db = await url_file_db.create(
24
33
  braid_blob.db_folder,
25
- braid_blob.meta_folder,
34
+ fake_meta_folder,
26
35
  async (db, key) => {
27
36
  // File changed externally, notify subscriptions
28
37
  // Use db parameter instead of braid_blob.db to avoid race condition
@@ -37,6 +46,37 @@ function create_braid_blob() {
37
46
  }
38
47
  }
39
48
 
49
+ function get_meta(key) {
50
+ if (braid_blob.meta_cache[key]) return braid_blob.meta_cache[key]
51
+ var meta_path = path.join(braid_blob.meta_folder, encode_filename(key))
52
+ try {
53
+ var data = fs.readFileSync(meta_path, 'utf8')
54
+ braid_blob.meta_cache[key] = JSON.parse(data)
55
+ return braid_blob.meta_cache[key]
56
+ } catch (e) {
57
+ if (e.code === 'ENOENT') return null
58
+ throw e
59
+ }
60
+ }
61
+
62
+ async function update_meta(key, updates) {
63
+ var meta = get_meta(key) || {}
64
+ Object.assign(meta, updates)
65
+ braid_blob.meta_cache[key] = meta
66
+ var meta_path = path.join(braid_blob.meta_folder, encode_filename(key))
67
+ await fs.promises.writeFile(meta_path, JSON.stringify(meta))
68
+ }
69
+
70
+ async function delete_meta(key) {
71
+ delete braid_blob.meta_cache[key]
72
+ var meta_path = path.join(braid_blob.meta_folder, encode_filename(key))
73
+ try {
74
+ await fs.promises.unlink(meta_path)
75
+ } catch (e) {
76
+ if (e.code !== 'ENOENT') throw e
77
+ }
78
+ }
79
+
40
80
  braid_blob.put = async (key, body, options = {}) => {
41
81
  // Handle URL case - make a remote PUT request
42
82
  if (key instanceof URL) {
@@ -59,8 +99,7 @@ function create_braid_blob() {
59
99
  await braid_blob.init()
60
100
  if (options.signal?.aborted) return
61
101
 
62
- // Read the meta data using new meta API
63
- var meta = braid_blob.db.get_meta(key) || {}
102
+ var meta = get_meta(key) || {}
64
103
 
65
104
  var their_e =
66
105
  !options.version ?
@@ -86,7 +125,7 @@ function create_braid_blob() {
86
125
  if (options.content_type)
87
126
  meta_updates.content_type = options.content_type
88
127
 
89
- await braid_blob.db.update_meta(key, meta_updates)
128
+ await update_meta(key, meta_updates)
90
129
  if (options.signal?.aborted) return
91
130
 
92
131
  // Notify all subscriptions of the update
@@ -134,8 +173,7 @@ function create_braid_blob() {
134
173
 
135
174
  await braid_blob.init()
136
175
 
137
- // Read the meta data using new meta API
138
- var meta = braid_blob.db.get_meta(key) || {}
176
+ var meta = get_meta(key) || {}
139
177
  if (meta.event == null) return null
140
178
 
141
179
  var result = {
@@ -217,8 +255,9 @@ function create_braid_blob() {
217
255
  await braid_blob.init()
218
256
  if (options.signal?.aborted) return
219
257
 
220
- // Delete the file from the database
258
+ // Delete the file and its metadata
221
259
  await braid_blob.db.delete(key)
260
+ await delete_meta(key)
222
261
 
223
262
  // TODO: notify subscribers of deletion once we have a protocol for that
224
263
  // For now, just clean up the subscriptions
@@ -416,17 +455,6 @@ function create_braid_blob() {
416
455
  }
417
456
 
418
457
  // Remote -> local: subscribe to remote updates
419
- // We need both: remote_res (for Editable header) and local file exists
420
- var got_remote_res, got_local_file
421
- var remote_res_promise = new Promise(done => got_remote_res = done)
422
- var local_file_promise = new Promise(done => got_local_file = done)
423
-
424
- // Apply read-only once we have both remote response and local file
425
- Promise.all([remote_res_promise, local_file_promise]).then(async () => {
426
- var read_only = remote_res.headers?.get('editable') === 'false'
427
- await braid_blob.db.set_read_only(a, read_only)
428
- })
429
-
430
458
  var b_ops = {
431
459
  signal: ac.signal,
432
460
  dont_retry: true,
@@ -435,7 +463,6 @@ function create_braid_blob() {
435
463
  version: update.version,
436
464
  content_type: update.headers?.['content-type']
437
465
  })
438
- got_local_file()
439
466
  remote_first_put()
440
467
  },
441
468
  on_error: handle_error
@@ -446,15 +473,11 @@ function create_braid_blob() {
446
473
  }
447
474
 
448
475
  // Set up both subscriptions, handling cases where one doesn't exist yet
449
- braid_blob.get(a, a_ops).then(x => {
450
- if (x) got_local_file()
451
- else remote_first_put_promise.then(() =>
452
- braid_blob.get(a, a_ops))
453
- })
476
+ braid_blob.get(a, a_ops).then(x =>
477
+ x || remote_first_put_promise.then(() =>
478
+ braid_blob.get(a, a_ops)))
454
479
 
455
- // Get the response to check Editable header
456
480
  var remote_res = await braid_blob.get(b, b_ops)
457
- if (remote_res) got_remote_res()
458
481
 
459
482
  // If remote doesn't exist yet, wait for it to be created then reconnect
460
483
  if (!remote_res) {
@@ -550,6 +573,35 @@ function create_braid_blob() {
550
573
  return false;
551
574
  }
552
575
 
576
+ function encode_filename(s) {
577
+ // Deal with case insensitivity
578
+ var bits = s.match(/\p{L}/ug).
579
+ map(c => +(c === c.toUpperCase())).join('')
580
+ var postfix = BigInt('0b0' + bits).toString(16)
581
+
582
+ // Swap ! and /
583
+ s = s.replace(/[\/!]/g, x => x === '/' ? '!' : '/')
584
+
585
+ // Encode characters that are unsafe on various filesystems:
586
+ // < > : " / \ | ? * - Windows restrictions
587
+ // % - Reserved for encoding
588
+ // \x00-\x1f, \x7f - Control characters
589
+ s = s.replace(/[<>:"/|\\?*%\x00-\x1f\x7f]/g, encode_char)
590
+
591
+ // Deal with windows reserved words
592
+ if (s.match(/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i))
593
+ s = s.slice(0, 2) + encode_char(s[2]) + s.slice(3)
594
+
595
+ // Deal with case insensitivity
596
+ s += '.' + postfix
597
+
598
+ return s
599
+
600
+ function encode_char(char) {
601
+ return '%' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')
602
+ }
603
+ }
604
+
553
605
  braid_blob.create_braid_blob = create_braid_blob
554
606
 
555
607
  return braid_blob
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-blob",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "Library for collaborative blobs over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-blob",
package/test/tests.js CHANGED
@@ -1021,8 +1021,8 @@ runTest(
1021
1021
  runTest(
1022
1022
  "test sync two local keys",
1023
1023
  async () => {
1024
- var key1 = 'test-sync-local1-' + Math.random().toString(36).slice(2)
1025
- var key2 = 'test-sync-local2-' + Math.random().toString(36).slice(2)
1024
+ var key1 = '/test-sync-local1-' + Math.random().toString(36).slice(2)
1025
+ var key2 = '/test-sync-local2-' + Math.random().toString(36).slice(2)
1026
1026
 
1027
1027
  var r1 = await braid_fetch(`/eval`, {
1028
1028
  method: 'POST',
@@ -1049,7 +1049,7 @@ runTest(
1049
1049
  await new Promise(done => setTimeout(done, 100))
1050
1050
 
1051
1051
  // Check second key has the content
1052
- var r = await braid_fetch(`/${key2}`)
1052
+ var r = await braid_fetch(`${key2}`)
1053
1053
  return await r.text()
1054
1054
  },
1055
1055
  'sync local content'
@@ -1058,11 +1058,11 @@ runTest(
1058
1058
  runTest(
1059
1059
  "test sync remote to local (swap)",
1060
1060
  async () => {
1061
- var local_key = 'test-sync-swap-local-' + Math.random().toString(36).slice(2)
1062
- var remote_key = 'test-sync-swap-remote-' + Math.random().toString(36).slice(2)
1061
+ var local_key = '/test-sync-swap-local-' + Math.random().toString(36).slice(2)
1062
+ var remote_key = '/test-sync-swap-remote-' + Math.random().toString(36).slice(2)
1063
1063
 
1064
1064
  // Put something on the server first
1065
- await braid_fetch(`/${remote_key}`, {
1065
+ await braid_fetch(`${remote_key}`, {
1066
1066
  method: 'PUT',
1067
1067
  version: ['800'],
1068
1068
  body: 'remote content'
@@ -1073,7 +1073,7 @@ runTest(
1073
1073
  body: `void (async () => {
1074
1074
  try {
1075
1075
  var braid_blob = require(\`\${__dirname}/../index.js\`)
1076
- var remote_url = new URL('http://localhost:' + req.socket.localPort + '/${remote_key}')
1076
+ var remote_url = new URL('http://localhost:' + req.socket.localPort + '${remote_key}')
1077
1077
 
1078
1078
  // Start sync with URL as first argument (should swap internally)
1079
1079
  braid_blob.sync(remote_url, '${local_key}')
@@ -1091,7 +1091,7 @@ runTest(
1091
1091
  await new Promise(done => setTimeout(done, 100))
1092
1092
 
1093
1093
  // Check local key has the remote content
1094
- var r = await braid_fetch(`/${local_key}`)
1094
+ var r = await braid_fetch(`${local_key}`)
1095
1095
  return await r.text()
1096
1096
  },
1097
1097
  'remote content'
@@ -1147,101 +1147,6 @@ runTest(
1147
1147
  'shared content'
1148
1148
  )
1149
1149
 
1150
- runTest(
1151
- "test sync readonly remote to nonexistent local",
1152
- async () => {
1153
- var local_key = 'test-sync-readonly-local-' + Math.random().toString(36).slice(2)
1154
- var remote_key = 'test-sync-readonly-remote-' + Math.random().toString(36).slice(2)
1155
-
1156
- // Put something on the server first with Editable: false
1157
- var r1 = await braid_fetch(`/eval`, {
1158
- method: 'POST',
1159
- body: `void (async () => {
1160
- try {
1161
- // Create a custom handler that sets Editable: false
1162
- req.method = 'PUT'
1163
- req.version = ['100']
1164
- req.body = Buffer.from('readonly content')
1165
- await braid_blob.put('${remote_key}', req.body, { version: req.version })
1166
- res.end('created')
1167
- } catch (e) {
1168
- res.end('error: ' + e.message + ' ' + e.stack)
1169
- }
1170
- })()`
1171
- })
1172
- var result = await r1.text()
1173
- if (result.startsWith('error:')) return result
1174
-
1175
- // Now sync to a local key that doesn't exist, with a readonly remote
1176
- var r2 = await braid_fetch(`/eval`, {
1177
- method: 'POST',
1178
- body: `void (async () => {
1179
- try {
1180
- var fs = require('fs').promises
1181
- var test_id = 'test-sync-readonly-' + Math.random().toString(36).slice(2)
1182
- var db_folder = __dirname + '/' + test_id + '-db'
1183
- var meta_folder = __dirname + '/' + test_id + '-meta'
1184
-
1185
- var bb = braid_blob.create_braid_blob()
1186
- bb.db_folder = db_folder
1187
- bb.meta_folder = meta_folder
1188
-
1189
- // Create a mock server URL that returns Editable: false
1190
- var http = require('http')
1191
- var {http_server: braidify} = require('braid-http')
1192
-
1193
- var mock_server = http.createServer((req2, res2) => {
1194
- braidify(req2, res2)
1195
- res2.setHeader('Editable', 'false')
1196
- braid_blob.serve(req2, res2, { key: '${remote_key}' })
1197
- })
1198
-
1199
- await new Promise(resolve => mock_server.listen(0, resolve))
1200
- var port = mock_server.address().port
1201
-
1202
- var remote_url = new URL('http://localhost:' + port + '/${remote_key}')
1203
- var ac = new AbortController()
1204
-
1205
- // Start sync - local key doesn't exist yet
1206
- bb.sync('${local_key}', remote_url, { signal: ac.signal })
1207
-
1208
- // Wait for sync to happen
1209
- await new Promise(done => setTimeout(done, 500))
1210
-
1211
- // Stop sync
1212
- ac.abort()
1213
- mock_server.close()
1214
-
1215
- // Check if local file exists and has the content
1216
- var result = await bb.get('${local_key}')
1217
-
1218
- var response
1219
- if (!result) {
1220
- response = 'file not created'
1221
- } else if (result.body.toString() !== 'readonly content') {
1222
- response = 'wrong content: ' + result.body.toString()
1223
- } else {
1224
- // Check if the file is actually read-only
1225
- await bb.init()
1226
- var is_readonly = await bb.db.is_read_only('${local_key}')
1227
- response = is_readonly ? 'synced and readonly' : 'synced but NOT readonly'
1228
- }
1229
-
1230
- // Clean up
1231
- await fs.rm(db_folder, { recursive: true, force: true })
1232
- await fs.rm(meta_folder, { recursive: true, force: true })
1233
-
1234
- res.end(response)
1235
- } catch (e) {
1236
- res.end('error: ' + e.message + ' ' + e.stack)
1237
- }
1238
- })()`
1239
- })
1240
- return await r2.text()
1241
- },
1242
- 'synced and readonly'
1243
- )
1244
-
1245
1150
  runTest(
1246
1151
  "test sync does not disconnect unnecessarily",
1247
1152
  async () => {