braid-text 0.2.99 → 0.2.101

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 (3) hide show
  1. package/index.js +54 -12
  2. package/package.json +1 -1
  3. package/test/tests.js +191 -0
package/index.js CHANGED
@@ -1293,17 +1293,43 @@ function create_braid_text() {
1293
1293
 
1294
1294
  async function db_folder_init() {
1295
1295
  if (braid_text.verbose) console.log('__!')
1296
- if (!db_folder_init.p) db_folder_init.p = new Promise(async done => {
1296
+ if (!db_folder_init.p) db_folder_init.p = (async () => {
1297
1297
  await fs.promises.mkdir(braid_text.db_folder, { recursive: true });
1298
1298
  await fs.promises.mkdir(`${braid_text.db_folder}/.meta`, { recursive: true })
1299
+ await fs.promises.mkdir(`${braid_text.db_folder}/.temp`, { recursive: true })
1300
+ await fs.promises.mkdir(`${braid_text.db_folder}/.wal-intent`, { recursive: true })
1301
+
1302
+ // Clean out .temp directory on startup
1303
+ var temp_files = await fs.promises.readdir(`${braid_text.db_folder}/.temp`)
1304
+ for (var f of temp_files)
1305
+ await fs.promises.unlink(`${braid_text.db_folder}/.temp/${f}`)
1306
+
1307
+ // Replay any pending .wal-intent files
1308
+ var intent_files = await fs.promises.readdir(`${braid_text.db_folder}/.wal-intent`)
1309
+ for (var intent_name of intent_files) {
1310
+ var intent_path = `${braid_text.db_folder}/.wal-intent/${intent_name}`
1311
+ var target_path = `${braid_text.db_folder}/${intent_name}`
1312
+
1313
+ var intent_data = await fs.promises.readFile(intent_path)
1314
+ var expected_size = Number(intent_data.readBigUInt64LE(0))
1315
+ var append_data = intent_data.subarray(8)
1316
+
1317
+ var stat = await fs.promises.stat(target_path)
1318
+ if (stat.size < expected_size || stat.size > expected_size + append_data.length)
1319
+ throw new Error(`wal-intent replay failed: ${target_path} size ${stat.size}, expected ${expected_size} to ${expected_size + append_data.length}`)
1320
+
1321
+ // Append whatever portion hasn't been written yet
1322
+ var already_written = stat.size - expected_size
1323
+ if (already_written < append_data.length)
1324
+ await fs.promises.appendFile(target_path, append_data.subarray(already_written))
1325
+ await fs.promises.unlink(intent_path)
1326
+ }
1299
1327
 
1300
1328
  // Populate key_to_filename mapping from existing files
1301
1329
  var files = (await fs.promises.readdir(braid_text.db_folder))
1302
1330
  .filter(x => /\.\d+$/.test(x))
1303
1331
  init_filename_mapping(files)
1304
-
1305
- done()
1306
- })
1332
+ })()
1307
1333
  await db_folder_init.p
1308
1334
  }
1309
1335
 
@@ -1385,10 +1411,20 @@ function create_braid_text() {
1385
1411
  if (currentSize < threshold) {
1386
1412
  if (braid_text.verbose) console.log(`appending to db..`)
1387
1413
 
1388
- let buffer = Buffer.allocUnsafe(4)
1389
- buffer.writeUInt32LE(bytes.length, 0)
1390
- await fs.promises.appendFile(filename, buffer)
1391
- await fs.promises.appendFile(filename, bytes)
1414
+ let len_buf = Buffer.allocUnsafe(4)
1415
+ len_buf.writeUInt32LE(bytes.length, 0)
1416
+ let append_data = Buffer.concat([len_buf, bytes])
1417
+
1418
+ let basename = require('path').basename(filename)
1419
+ let intent_path = `${braid_text.db_folder}/.wal-intent/${basename}`
1420
+ let stat = await fs.promises.stat(filename)
1421
+ let size_buf = Buffer.allocUnsafe(8)
1422
+ size_buf.writeBigUInt64LE(BigInt(stat.size), 0)
1423
+
1424
+ await atomic_write(intent_path, Buffer.concat([size_buf, append_data]),
1425
+ `${braid_text.db_folder}/.temp`)
1426
+ await fs.promises.appendFile(filename, append_data)
1427
+ await fs.promises.unlink(intent_path)
1392
1428
 
1393
1429
  if (braid_text.verbose) console.log("wrote to : " + filename)
1394
1430
  } else {
@@ -1401,8 +1437,8 @@ function create_braid_text() {
1401
1437
  buffer.writeUInt32LE(init.length, 0)
1402
1438
 
1403
1439
  const newFilename = `${braid_text.db_folder}/${encoded}.${currentNumber}`
1404
- await fs.promises.writeFile(newFilename, buffer)
1405
- await fs.promises.appendFile(newFilename, init)
1440
+ await atomic_write(newFilename, Buffer.concat([buffer, init]),
1441
+ `${braid_text.db_folder}/.temp`)
1406
1442
 
1407
1443
  if (braid_text.verbose) console.log("wrote to : " + newFilename)
1408
1444
 
@@ -1423,8 +1459,8 @@ function create_braid_text() {
1423
1459
 
1424
1460
  while (meta_dirty) {
1425
1461
  meta_dirty = false
1426
- await fs.promises.writeFile(meta_filename,
1427
- JSON.stringify(get_meta()))
1462
+ await atomic_write(meta_filename, JSON.stringify(get_meta()),
1463
+ `${braid_text.db_folder}/.temp`)
1428
1464
  await new Promise(done => setTimeout(done,
1429
1465
  braid_text.meta_file_save_period_ms))
1430
1466
  }
@@ -2727,6 +2763,12 @@ function create_braid_text() {
2727
2763
  return `sha-256=:${require('crypto').createHash('sha256').update(s).digest('base64')}:`
2728
2764
  }
2729
2765
 
2766
+ async function atomic_write(final_destination, data, temp_folder) {
2767
+ var temp = `${temp_folder}/${Math.random().toString(36).slice(2)}`
2768
+ await fs.promises.writeFile(temp, data)
2769
+ await fs.promises.rename(temp, final_destination)
2770
+ }
2771
+
2730
2772
  function within_fiber(id, func) {
2731
2773
  if (!within_fiber.chains) within_fiber.chains = {}
2732
2774
  var prev = within_fiber.chains[id] || Promise.resolve()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.2.99",
3
+ "version": "0.2.101",
4
4
  "description": "Library for collaborative text over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-text",
package/test/tests.js CHANGED
@@ -2665,6 +2665,197 @@ runTest(
2665
2665
  'filename conflict detected'
2666
2666
  )
2667
2667
 
2668
+ runTest(
2669
+ "test wal-intent recovery after simulated crash during append",
2670
+ async () => {
2671
+ var r = await braid_fetch(`/eval`, {
2672
+ method: 'PUT',
2673
+ body: `void (async () => {
2674
+ var fs = require('fs')
2675
+ var test_db = __dirname + '/test_wal_recovery_' + Math.random().toString(36).slice(2)
2676
+ try {
2677
+ // Create a fresh braid_text instance with its own db_folder
2678
+ var bt = braid_text.create_braid_text()
2679
+ bt.db_folder = test_db
2680
+
2681
+ // Do initial PUT (5 chars = 'hello', version ends at 4)
2682
+ await bt.put('/test', {
2683
+ version: ['a-4'],
2684
+ parents: [],
2685
+ body: 'hello'
2686
+ })
2687
+
2688
+ // Do second PUT to trigger an append (6 more chars = ' world', version ends at 10)
2689
+ await bt.put('/test', {
2690
+ version: ['a-10'],
2691
+ parents: ['a-4'],
2692
+ body: 'hello world'
2693
+ })
2694
+
2695
+ var encoded = bt.encode_filename('/test')
2696
+ var db_file = test_db + '/' + encoded + '.1'
2697
+ var intent_file = test_db + '/.wal-intent/' + encoded + '.1'
2698
+
2699
+ // Read the db file
2700
+ var data = await fs.promises.readFile(db_file)
2701
+
2702
+ // Parse chunks to find the last one
2703
+ var cursor = 0
2704
+ var chunks = []
2705
+ while (cursor < data.length) {
2706
+ var chunk_start = cursor
2707
+ var chunk_size = data.readUInt32LE(cursor)
2708
+ cursor += 4 + chunk_size
2709
+ chunks.push({ start: chunk_start, size: chunk_size, end: cursor })
2710
+ }
2711
+
2712
+ if (chunks.length < 2) {
2713
+ res.end('expected at least 2 chunks, got ' + chunks.length)
2714
+ return
2715
+ }
2716
+
2717
+ var last_chunk = chunks[chunks.length - 1]
2718
+ var prev_end = chunks[chunks.length - 2].end
2719
+
2720
+ // Create the wal-intent file: 8-byte size + the last chunk data
2721
+ var size_buf = Buffer.allocUnsafe(8)
2722
+ size_buf.writeBigUInt64LE(BigInt(prev_end), 0)
2723
+ var last_chunk_data = data.subarray(last_chunk.start, last_chunk.end)
2724
+ var intent_data = Buffer.concat([size_buf, last_chunk_data])
2725
+ await fs.promises.writeFile(intent_file, intent_data)
2726
+
2727
+ // Truncate the db file partway through the last chunk (keep ~half)
2728
+ var truncate_point = last_chunk.start + Math.floor((last_chunk.end - last_chunk.start) / 2)
2729
+ await fs.promises.truncate(db_file, truncate_point)
2730
+
2731
+ // Create a new braid_text instance to simulate restart
2732
+ var bt2 = braid_text.create_braid_text()
2733
+ bt2.db_folder = test_db
2734
+
2735
+ // Get the resource - this should trigger wal-intent replay
2736
+ var resource = await bt2.get_resource('/test')
2737
+ var text = resource.doc.get()
2738
+
2739
+ // Verify intent file was cleaned up
2740
+ var intent_exists = true
2741
+ try {
2742
+ await fs.promises.access(intent_file)
2743
+ } catch (e) {
2744
+ intent_exists = false
2745
+ }
2746
+
2747
+ await fs.promises.rm(test_db, { recursive: true, force: true })
2748
+
2749
+ if (intent_exists) {
2750
+ res.end('intent file still exists')
2751
+ return
2752
+ }
2753
+
2754
+ res.end(text)
2755
+ } catch (e) {
2756
+ await fs.promises.rm(test_db, { recursive: true, force: true }).catch(() => {})
2757
+ res.end('error: ' + e.message + ' ' + e.stack)
2758
+ }
2759
+ })()`
2760
+ })
2761
+
2762
+ return await r.text()
2763
+ },
2764
+ 'hello world'
2765
+ )
2766
+
2767
+ runTest(
2768
+ "test wal-intent throws error when db file is too large",
2769
+ async () => {
2770
+ var r = await braid_fetch(`/eval`, {
2771
+ method: 'PUT',
2772
+ body: `void (async () => {
2773
+ var fs = require('fs')
2774
+ var test_db = __dirname + '/test_wal_error_' + Math.random().toString(36).slice(2)
2775
+ try {
2776
+ // Create a fresh braid_text instance with its own db_folder
2777
+ var bt = braid_text.create_braid_text()
2778
+ bt.db_folder = test_db
2779
+
2780
+ // Do initial PUT (5 chars = 'hello', version ends at 4)
2781
+ await bt.put('/test', {
2782
+ version: ['a-4'],
2783
+ parents: [],
2784
+ body: 'hello'
2785
+ })
2786
+
2787
+ // Do second PUT to trigger an append (6 more chars = ' world', version ends at 10)
2788
+ await bt.put('/test', {
2789
+ version: ['a-10'],
2790
+ parents: ['a-4'],
2791
+ body: 'hello world'
2792
+ })
2793
+
2794
+ var encoded = bt.encode_filename('/test')
2795
+ var db_file = test_db + '/' + encoded + '.1'
2796
+ var intent_file = test_db + '/.wal-intent/' + encoded + '.1'
2797
+
2798
+ // Read the db file
2799
+ var data = await fs.promises.readFile(db_file)
2800
+
2801
+ // Parse chunks to find the last one
2802
+ var cursor = 0
2803
+ var chunks = []
2804
+ while (cursor < data.length) {
2805
+ var chunk_start = cursor
2806
+ var chunk_size = data.readUInt32LE(cursor)
2807
+ cursor += 4 + chunk_size
2808
+ chunks.push({ start: chunk_start, size: chunk_size, end: cursor })
2809
+ }
2810
+
2811
+ if (chunks.length < 2) {
2812
+ res.end('expected at least 2 chunks, got ' + chunks.length)
2813
+ return
2814
+ }
2815
+
2816
+ var last_chunk = chunks[chunks.length - 1]
2817
+ var prev_end = chunks[chunks.length - 2].end
2818
+
2819
+ // Create the wal-intent file: 8-byte size + the last chunk data
2820
+ var size_buf = Buffer.allocUnsafe(8)
2821
+ size_buf.writeBigUInt64LE(BigInt(prev_end), 0)
2822
+ var last_chunk_data = data.subarray(last_chunk.start, last_chunk.end)
2823
+ var intent_data = Buffer.concat([size_buf, last_chunk_data])
2824
+ await fs.promises.writeFile(intent_file, intent_data)
2825
+
2826
+ // Append extra garbage to the db file (making it too large)
2827
+ await fs.promises.appendFile(db_file, Buffer.from('extra garbage data'))
2828
+
2829
+ // Create a new braid_text instance to simulate restart
2830
+ var bt2 = braid_text.create_braid_text()
2831
+ bt2.db_folder = test_db
2832
+
2833
+ // Now try to init - this should throw an error
2834
+ var result
2835
+ try {
2836
+ await bt2.db_folder_init()
2837
+ result = 'should have thrown an error'
2838
+ } catch (e) {
2839
+ if (e.message.includes('wal-intent replay failed')) {
2840
+ result = 'correctly threw error'
2841
+ } else {
2842
+ result = 'wrong error: ' + e.message
2843
+ }
2844
+ }
2845
+ await fs.promises.rm(test_db, { recursive: true, force: true })
2846
+ res.end(result)
2847
+ } catch (e) {
2848
+ await fs.promises.rm(test_db, { recursive: true, force: true }).catch(() => {})
2849
+ res.end('error: ' + e.message)
2850
+ }
2851
+ })()`
2852
+ })
2853
+
2854
+ return await r.text()
2855
+ },
2856
+ 'correctly threw error'
2857
+ )
2858
+
2668
2859
  }
2669
2860
 
2670
2861
  // Export for both Node.js and browser environments