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 +4 -4
- package/README.md +1 -1
- package/index.js +80 -28
- package/package.json +1 -1
- package/test/tests.js +8 -103
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:
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
451
|
-
|
|
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
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(
|
|
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(
|
|
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 + '
|
|
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(
|
|
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 () => {
|