@fizzyflow/wdoublesync 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/README.md +165 -0
- package/WDoubleSync.js +450 -0
- package/index.js +27 -0
- package/package.json +34 -0
- package/test/fixture.js +84 -0
- package/test/helpers.js +66 -0
- package/test/wdoublesync.test.js +472 -0
- package/test/wdoublesync_deletion.test.js +244 -0
- package/test/wdoublesync_with_walrus.test.js +371 -0
- package/vitest.config.js +13 -0
package/test/fixture.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fileURLToPath } from 'url';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { SuiMaster, SuiLocalTestValidator } from 'suidouble';
|
|
4
|
+
import LocalnodeWalrusTestState from '../../seal_walrus_localnet/includes/LocalnodeWalrusTestState.js';
|
|
5
|
+
import LocalnodeWalrusTestServer from '../../seal_walrus_localnet/includes/LocalnodeWalrusTestServer.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const MOVE_PKG_PATH = path.join(__dirname, '../../endless_vector/move');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Process-scoped cache — shared across all test files in the same vitest worker
|
|
12
|
+
* (requires singleFork: true + isolate: false in vitest.config.js).
|
|
13
|
+
*
|
|
14
|
+
* @type {Promise<{ suiMaster, walrusState, walrusServer, walrusClient, packageId }> | null}
|
|
15
|
+
*/
|
|
16
|
+
let cached = null;
|
|
17
|
+
let exitHookInstalled = false;
|
|
18
|
+
|
|
19
|
+
function installExitHook() {
|
|
20
|
+
if (exitHookInstalled) return;
|
|
21
|
+
exitHookInstalled = true;
|
|
22
|
+
process.once('beforeExit', async () => {
|
|
23
|
+
try {
|
|
24
|
+
const { walrusServer } = await cached;
|
|
25
|
+
await walrusServer?.stop();
|
|
26
|
+
} catch { /* best-effort */ }
|
|
27
|
+
try { await SuiLocalTestValidator.stop(); } catch { /* best-effort */ }
|
|
28
|
+
});
|
|
29
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
30
|
+
process.once(sig, () => {
|
|
31
|
+
(cached ? cached.then(({ walrusServer }) => walrusServer?.stop()).catch(() => {}) : Promise.resolve())
|
|
32
|
+
.then(() => SuiLocalTestValidator.stop())
|
|
33
|
+
.finally(() => process.exit(0));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Boot the local validator, deploy the endless_vector package, and start a
|
|
40
|
+
* WalrusTestServer. Idempotent — the first call does the work, subsequent
|
|
41
|
+
* calls return the same handles.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ debug?: boolean }} [opts]
|
|
44
|
+
*/
|
|
45
|
+
export function setupLocalnet(opts = {}) {
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
installExitHook();
|
|
48
|
+
cached = (async () => {
|
|
49
|
+
const debug = !!opts.debug;
|
|
50
|
+
const validator = await SuiLocalTestValidator.launch({ debug });
|
|
51
|
+
if (!validator.active) throw new Error('local test validator failed to start');
|
|
52
|
+
|
|
53
|
+
const suiMaster = new SuiMaster({ client: validator, as: 'wds_tester', debug });
|
|
54
|
+
await suiMaster.initialize();
|
|
55
|
+
await suiMaster.requestSuiFromFaucet();
|
|
56
|
+
await suiMaster.requestSuiFromFaucet();
|
|
57
|
+
await suiMaster.requestSuiFromFaucet();
|
|
58
|
+
|
|
59
|
+
const walrusState = new LocalnodeWalrusTestState({
|
|
60
|
+
suiMaster,
|
|
61
|
+
packagePath: MOVE_PKG_PATH,
|
|
62
|
+
epochDuration: 30_000,
|
|
63
|
+
});
|
|
64
|
+
await walrusState.deploy();
|
|
65
|
+
|
|
66
|
+
const packageId = walrusState.walrusPackageId;
|
|
67
|
+
|
|
68
|
+
const walrusServer = new LocalnodeWalrusTestServer({ state: walrusState });
|
|
69
|
+
await walrusServer.start();
|
|
70
|
+
|
|
71
|
+
const walrusClient = await walrusServer.getWalrusClient({ suiMaster });
|
|
72
|
+
|
|
73
|
+
return { suiMaster, walrusState, walrusServer, walrusClient, packageId };
|
|
74
|
+
})().catch((err) => {
|
|
75
|
+
cached = null;
|
|
76
|
+
throw err;
|
|
77
|
+
});
|
|
78
|
+
return cached;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** No-op per-suite — cleanup is handled by the beforeExit hook. */
|
|
82
|
+
export async function teardownLocalnet() {
|
|
83
|
+
// intentionally empty
|
|
84
|
+
}
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
function equalUint8Arrays(a, b) {
|
|
4
|
+
if (a.length !== b.length) return false;
|
|
5
|
+
for (let i = 0; i < a.length; i++) {
|
|
6
|
+
if (a[i] !== b[i]) return false;
|
|
7
|
+
}
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function randomBytesOfLength(length) {
|
|
12
|
+
return new Uint8Array(crypto.randomBytes(length));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deterministic pseudo-random bytes via xorshift32 (for reproducible tree content).
|
|
17
|
+
* @param {number} n
|
|
18
|
+
* @param {number} [seed=1]
|
|
19
|
+
* @returns {Uint8Array}
|
|
20
|
+
*/
|
|
21
|
+
function seededBytes(n, seed = 1) {
|
|
22
|
+
let s = (seed * 2654435761) >>> 0;
|
|
23
|
+
if (s === 0) s = 0xdeadbeef;
|
|
24
|
+
const out = new Uint8Array(n);
|
|
25
|
+
for (let i = 0; i < n; i++) {
|
|
26
|
+
s ^= s << 13;
|
|
27
|
+
s ^= s >>> 17;
|
|
28
|
+
s ^= s << 5;
|
|
29
|
+
s >>>= 0;
|
|
30
|
+
out[i] = s & 0xff;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Walk a DoubleSyncFolder and return all files as `{ path, bytes }`.
|
|
37
|
+
*/
|
|
38
|
+
async function collectTree(folder, prefix = []) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for await (const { path, file } of folder.walk(prefix)) {
|
|
41
|
+
out.push({ path, bytes: await file.getContent() });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compare two DoubleSyncFolder trees for byte-level equality.
|
|
48
|
+
*/
|
|
49
|
+
async function treesEqual(a, b) {
|
|
50
|
+
const ta = await collectTree(a);
|
|
51
|
+
const tb = await collectTree(b);
|
|
52
|
+
if (ta.length !== tb.length) return false;
|
|
53
|
+
for (let i = 0; i < ta.length; i++) {
|
|
54
|
+
if (ta[i].path.join('/') !== tb[i].path.join('/')) return false;
|
|
55
|
+
if (!equalUint8Arrays(ta[i].bytes, tb[i].bytes)) return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
equalUint8Arrays,
|
|
62
|
+
randomBytesOfLength,
|
|
63
|
+
seededBytes,
|
|
64
|
+
collectTree,
|
|
65
|
+
treesEqual,
|
|
66
|
+
};
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for WDoubleSync — stores DoubleSync patches inside a real
|
|
3
|
+
* EndlessVector on a local Sui blockchain node.
|
|
4
|
+
*
|
|
5
|
+
* Uses the same seal_walrus_localnet infrastructure as EndlessVector's own tests:
|
|
6
|
+
* local validator + Walrus mock server + deployed endless_vector Move package.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeAll, afterAll, describe, it, expect } from 'vitest';
|
|
10
|
+
import { EndlessVector } from 'endless_vector';
|
|
11
|
+
import {
|
|
12
|
+
DoubleSync,
|
|
13
|
+
DoubleSyncMemoryFolder,
|
|
14
|
+
DoubleSyncFormat,
|
|
15
|
+
DoubleSyncPatch,
|
|
16
|
+
DoubleSyncDiffPatch,
|
|
17
|
+
DoubleSyncCompressed,
|
|
18
|
+
DoubleSyncFile,
|
|
19
|
+
DoubleSyncFolder,
|
|
20
|
+
} from 'doublesync';
|
|
21
|
+
import WDoubleSync from '../WDoubleSync.js';
|
|
22
|
+
import { equalUint8Arrays, randomBytesOfLength, seededBytes, collectTree, treesEqual } from './helpers.js';
|
|
23
|
+
import { setupLocalnet, teardownLocalnet } from './fixture.js';
|
|
24
|
+
|
|
25
|
+
const TX_TIMEOUT = 60_000;
|
|
26
|
+
|
|
27
|
+
let suiMaster;
|
|
28
|
+
let walrusServer;
|
|
29
|
+
let walrusClient;
|
|
30
|
+
let packageId;
|
|
31
|
+
|
|
32
|
+
// ─── setup ───────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
({ suiMaster, walrusServer, walrusClient, packageId } = await setupLocalnet());
|
|
36
|
+
console.log('package id:', packageId);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await teardownLocalnet();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function makeSignAndExecute() {
|
|
46
|
+
return async (tx) => {
|
|
47
|
+
const result = await suiMaster.signAndExecuteTransaction({
|
|
48
|
+
transaction: tx,
|
|
49
|
+
});
|
|
50
|
+
return result.digest;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function createEV() {
|
|
55
|
+
return EndlessVector.create({
|
|
56
|
+
suiClient: suiMaster.client,
|
|
57
|
+
packageId,
|
|
58
|
+
walrusClient,
|
|
59
|
+
aggregatorUrl: walrusServer?.url,
|
|
60
|
+
senderAddress: suiMaster.address,
|
|
61
|
+
signAndExecuteTransaction: makeSignAndExecute(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeEV(id) {
|
|
66
|
+
return new EndlessVector({
|
|
67
|
+
suiClient: suiMaster.client,
|
|
68
|
+
id,
|
|
69
|
+
packageId,
|
|
70
|
+
walrusClient,
|
|
71
|
+
aggregatorUrl: walrusServer?.url,
|
|
72
|
+
senderAddress: suiMaster.address,
|
|
73
|
+
signAndExecuteTransaction: makeSignAndExecute(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeSync() {
|
|
78
|
+
return new DoubleSync({ avgSize: 1024 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── single push + restore ───────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('single push and restore', () => {
|
|
84
|
+
it('pushes a tree and restores it identically', async () => {
|
|
85
|
+
const ev = await createEV();
|
|
86
|
+
const w = new WDoubleSync({ endlessVector: ev, sync: makeSync() });
|
|
87
|
+
|
|
88
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
89
|
+
await sender.addFile('readme.md', seededBytes(2048, 1));
|
|
90
|
+
const src = await sender.addFolder('src');
|
|
91
|
+
await src.addFile('index.js', seededBytes(4096, 2));
|
|
92
|
+
await src.addFile('utils.js', seededBytes(3072, 3));
|
|
93
|
+
|
|
94
|
+
await w.initialize();
|
|
95
|
+
const { version } = await w.push(sender);
|
|
96
|
+
expect(version).toBe(1);
|
|
97
|
+
|
|
98
|
+
// Read back from a separate reader instance
|
|
99
|
+
const evReader = makeEV(ev.id);
|
|
100
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
101
|
+
const restored = await w2.restore();
|
|
102
|
+
expect(await treesEqual(sender, restored)).toBe(true);
|
|
103
|
+
}, TX_TIMEOUT);
|
|
104
|
+
|
|
105
|
+
it('first push produces a full DoubleSyncPatch on chain', async () => {
|
|
106
|
+
const ev = await createEV();
|
|
107
|
+
const w = new WDoubleSync({ endlessVector: ev, sync: makeSync() });
|
|
108
|
+
|
|
109
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
110
|
+
await sender.addFile('a.txt', seededBytes(512, 10));
|
|
111
|
+
|
|
112
|
+
await w.initialize();
|
|
113
|
+
await w.push(sender);
|
|
114
|
+
|
|
115
|
+
const raw = await ev.at(0);
|
|
116
|
+
expect(DoubleSyncFormat.detect(raw)).toBe('patch');
|
|
117
|
+
}, TX_TIMEOUT);
|
|
118
|
+
|
|
119
|
+
it('restore of empty vector returns empty folder', async () => {
|
|
120
|
+
const ev = await createEV();
|
|
121
|
+
const w = new WDoubleSync({ endlessVector: ev });
|
|
122
|
+
const folder = await w.restore();
|
|
123
|
+
expect(folder).toBeInstanceOf(DoubleSyncMemoryFolder);
|
|
124
|
+
const children = await folder.list();
|
|
125
|
+
expect(children.length).toBe(0);
|
|
126
|
+
}, TX_TIMEOUT);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ─── multi-version push + restore ────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe('multi-version push and restore', () => {
|
|
132
|
+
it('chain of 3 edits: full then diffs, all restore correctly', async () => {
|
|
133
|
+
const ev = await createEV();
|
|
134
|
+
const sync = makeSync();
|
|
135
|
+
const w = new WDoubleSync({ endlessVector: ev, sync });
|
|
136
|
+
|
|
137
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
138
|
+
await w.initialize();
|
|
139
|
+
|
|
140
|
+
// v1: initial tree
|
|
141
|
+
await sender.addFile('readme.md', seededBytes(2048, 20));
|
|
142
|
+
const src = await sender.addFolder('src');
|
|
143
|
+
await src.addFile('index.js', seededBytes(4096, 21));
|
|
144
|
+
await w.push(sender);
|
|
145
|
+
|
|
146
|
+
// v2: add a file
|
|
147
|
+
await src.addFile('utils.js', seededBytes(3072, 22));
|
|
148
|
+
await w.push(sender);
|
|
149
|
+
|
|
150
|
+
// v3: edit readme
|
|
151
|
+
const readme = await sender.findByPath(['readme.md']);
|
|
152
|
+
const old = await readme.getContent();
|
|
153
|
+
const edited = new Uint8Array(old.length + 1);
|
|
154
|
+
edited.set(old.subarray(0, 512));
|
|
155
|
+
edited[512] = 0x21;
|
|
156
|
+
edited.set(old.subarray(512), 513);
|
|
157
|
+
await readme.setContent(edited);
|
|
158
|
+
await w.push(sender);
|
|
159
|
+
|
|
160
|
+
await ev.reInitialize();
|
|
161
|
+
await ev.initialize();
|
|
162
|
+
expect(ev.length).toBe(3);
|
|
163
|
+
|
|
164
|
+
// First item is a full patch, rest are diff patches
|
|
165
|
+
expect(DoubleSyncFormat.detect(await ev.at(0))).toBe('patch');
|
|
166
|
+
expect(DoubleSyncFormat.detect(await ev.at(1))).toBe('diff-patch');
|
|
167
|
+
expect(DoubleSyncFormat.detect(await ev.at(2))).toBe('diff-patch');
|
|
168
|
+
|
|
169
|
+
// Restore latest
|
|
170
|
+
const evReader = makeEV(ev.id);
|
|
171
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
172
|
+
const latest = await w2.restore();
|
|
173
|
+
expect(await treesEqual(sender, latest)).toBe(true);
|
|
174
|
+
|
|
175
|
+
// Restore v1 only
|
|
176
|
+
const v1 = await w2.restore(1);
|
|
177
|
+
const v1files = await collectTree(v1);
|
|
178
|
+
const v1paths = v1files.map(f => f.path.join('/'));
|
|
179
|
+
expect(v1paths).toContain('readme.md');
|
|
180
|
+
expect(v1paths).toContain('src/index.js');
|
|
181
|
+
expect(v1paths).not.toContain('src/utils.js');
|
|
182
|
+
|
|
183
|
+
// Restore v2
|
|
184
|
+
const v2 = await w2.restore(2);
|
|
185
|
+
const v2files = await collectTree(v2);
|
|
186
|
+
const v2paths = v2files.map(f => f.path.join('/'));
|
|
187
|
+
expect(v2paths).toContain('src/utils.js');
|
|
188
|
+
}, TX_TIMEOUT * 3);
|
|
189
|
+
|
|
190
|
+
it('diff patches are smaller than the full patch', async () => {
|
|
191
|
+
const ev = await createEV();
|
|
192
|
+
const sync = makeSync();
|
|
193
|
+
const w = new WDoubleSync({ endlessVector: ev, sync });
|
|
194
|
+
|
|
195
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
196
|
+
await sender.addFile('big.bin', seededBytes(16 * 1024, 30));
|
|
197
|
+
await sender.addFile('other.bin', seededBytes(8 * 1024, 31));
|
|
198
|
+
|
|
199
|
+
await w.initialize();
|
|
200
|
+
await w.push(sender);
|
|
201
|
+
|
|
202
|
+
// Small edit
|
|
203
|
+
const big = await sender.findByPath(['big.bin']);
|
|
204
|
+
const old = await big.getContent();
|
|
205
|
+
const edited = new Uint8Array(old.length + 1);
|
|
206
|
+
edited.set(old.subarray(0, 4096));
|
|
207
|
+
edited[4096] = 0xab;
|
|
208
|
+
edited.set(old.subarray(4096), 4097);
|
|
209
|
+
await big.setContent(edited);
|
|
210
|
+
|
|
211
|
+
await w.push(sender);
|
|
212
|
+
|
|
213
|
+
const fullPatchSize = (await ev.at(0)).length;
|
|
214
|
+
const diffPatchSize = (await ev.at(1)).length;
|
|
215
|
+
expect(diffPatchSize).toBeLessThan(fullPatchSize);
|
|
216
|
+
}, TX_TIMEOUT * 2);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ─── initialize rebuilds state ───────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe('initialize rebuilds sender state from chain', () => {
|
|
222
|
+
it('new WDoubleSync on a populated vector continues with diff patches', async () => {
|
|
223
|
+
const ev = await createEV();
|
|
224
|
+
const sync = makeSync();
|
|
225
|
+
|
|
226
|
+
// First sender pushes 2 versions
|
|
227
|
+
const w1 = new WDoubleSync({ endlessVector: ev, sync });
|
|
228
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
229
|
+
await sender.addFile('a.txt', seededBytes(2048, 40));
|
|
230
|
+
await w1.initialize();
|
|
231
|
+
await w1.push(sender);
|
|
232
|
+
|
|
233
|
+
await sender.addFile('b.txt', seededBytes(2048, 41));
|
|
234
|
+
await w1.push(sender);
|
|
235
|
+
|
|
236
|
+
// Second sender creates a fresh WDoubleSync on the same vector
|
|
237
|
+
const ev2 = makeEV(ev.id);
|
|
238
|
+
const w2 = new WDoubleSync({ endlessVector: ev2, sync: makeSync() });
|
|
239
|
+
await w2.initialize();
|
|
240
|
+
|
|
241
|
+
// Next push should be a diff patch (not a full patch)
|
|
242
|
+
await sender.addFile('c.txt', seededBytes(1024, 42));
|
|
243
|
+
await w2.push(sender);
|
|
244
|
+
|
|
245
|
+
await ev2.reInitialize();
|
|
246
|
+
await ev2.initialize();
|
|
247
|
+
expect(ev2.length).toBe(3);
|
|
248
|
+
expect(DoubleSyncFormat.detect(await ev2.at(2))).toBe('diff-patch');
|
|
249
|
+
|
|
250
|
+
// Restore validates full chain
|
|
251
|
+
const evReader = makeEV(ev.id);
|
|
252
|
+
const w3 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
253
|
+
const restored = await w3.restore();
|
|
254
|
+
expect(await treesEqual(sender, restored)).toBe(true);
|
|
255
|
+
}, TX_TIMEOUT * 3);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ─── getPatch ────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
describe('getPatch', () => {
|
|
261
|
+
it('returns raw patch bytes at index', async () => {
|
|
262
|
+
const ev = await createEV();
|
|
263
|
+
const w = new WDoubleSync({ endlessVector: ev, sync: makeSync() });
|
|
264
|
+
|
|
265
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
266
|
+
await sender.addFile('file.txt', seededBytes(512, 50));
|
|
267
|
+
await w.initialize();
|
|
268
|
+
await w.push(sender);
|
|
269
|
+
|
|
270
|
+
const raw = await w.getPatch(0);
|
|
271
|
+
expect(raw).toBeInstanceOf(Uint8Array);
|
|
272
|
+
expect(DoubleSyncFormat.isDoubleSync(raw)).toBe(true);
|
|
273
|
+
}, TX_TIMEOUT);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ─── length ──────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
describe('length', () => {
|
|
279
|
+
it('reflects chain length after pushes', async () => {
|
|
280
|
+
const ev = await createEV();
|
|
281
|
+
const sync = makeSync();
|
|
282
|
+
const w = new WDoubleSync({ endlessVector: ev, sync });
|
|
283
|
+
|
|
284
|
+
expect(await w.length()).toBe(0);
|
|
285
|
+
|
|
286
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
287
|
+
await sender.addFile('a.txt', seededBytes(256, 60));
|
|
288
|
+
await w.initialize();
|
|
289
|
+
await w.push(sender);
|
|
290
|
+
|
|
291
|
+
expect(await w.length()).toBe(1);
|
|
292
|
+
|
|
293
|
+
await sender.addFile('b.txt', seededBytes(256, 61));
|
|
294
|
+
await w.push(sender);
|
|
295
|
+
|
|
296
|
+
expect(await w.length()).toBe(2);
|
|
297
|
+
}, TX_TIMEOUT * 2);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ─── compression ─────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
describe('compression', () => {
|
|
303
|
+
it('compressed patches are stored on chain and restored correctly', async () => {
|
|
304
|
+
const ev = await createEV();
|
|
305
|
+
const sync = makeSync();
|
|
306
|
+
const w = new WDoubleSync({ endlessVector: ev, sync, compress: 'gzip' });
|
|
307
|
+
|
|
308
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
309
|
+
await sender.addFile('readme.md', seededBytes(2048, 70));
|
|
310
|
+
const src = await sender.addFolder('src');
|
|
311
|
+
await src.addFile('index.js', seededBytes(4096, 71));
|
|
312
|
+
|
|
313
|
+
await w.initialize();
|
|
314
|
+
await w.push(sender);
|
|
315
|
+
|
|
316
|
+
// First patch should be compressed on chain
|
|
317
|
+
const raw = await ev.at(0);
|
|
318
|
+
expect(DoubleSyncFormat.detect(raw)).toBe('compressed');
|
|
319
|
+
|
|
320
|
+
// Edit and push again
|
|
321
|
+
await sender.addFile('changelog.md', seededBytes(1024, 72));
|
|
322
|
+
await w.push(sender);
|
|
323
|
+
|
|
324
|
+
const raw2 = await ev.at(1);
|
|
325
|
+
expect(DoubleSyncFormat.detect(raw2)).toBe('compressed');
|
|
326
|
+
|
|
327
|
+
// Restore from a non-compressed reader (auto-detects)
|
|
328
|
+
const evReader = makeEV(ev.id);
|
|
329
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
330
|
+
const restored = await w2.restore();
|
|
331
|
+
expect(await treesEqual(sender, restored)).toBe(true);
|
|
332
|
+
}, TX_TIMEOUT * 2);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ─── no-op push ──────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe('no-op push', () => {
|
|
338
|
+
it('no-change push produces a diff with zero ops', async () => {
|
|
339
|
+
const ev = await createEV();
|
|
340
|
+
const sync = makeSync();
|
|
341
|
+
const w = new WDoubleSync({ endlessVector: ev, sync });
|
|
342
|
+
|
|
343
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
344
|
+
await sender.addFile('stable.txt', seededBytes(4096, 80));
|
|
345
|
+
|
|
346
|
+
await w.initialize();
|
|
347
|
+
await w.push(sender);
|
|
348
|
+
|
|
349
|
+
// Push again with no changes
|
|
350
|
+
await w.push(sender);
|
|
351
|
+
|
|
352
|
+
const diffBytes = await ev.at(1);
|
|
353
|
+
expect(DoubleSyncFormat.detect(diffBytes)).toBe('diff-patch');
|
|
354
|
+
const parsed = new DoubleSyncDiffPatch(diffBytes);
|
|
355
|
+
expect(parsed.opCount).toBe(0);
|
|
356
|
+
expect(parsed.chunkCount).toBe(0);
|
|
357
|
+
|
|
358
|
+
// Restore still works
|
|
359
|
+
const evReader = makeEV(ev.id);
|
|
360
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
361
|
+
const restored = await w2.restore();
|
|
362
|
+
expect(await treesEqual(sender, restored)).toBe(true);
|
|
363
|
+
}, TX_TIMEOUT * 2);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ─── empty folders ───────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
describe('empty folders', () => {
|
|
369
|
+
it('preserves empty folders through push and restore', async () => {
|
|
370
|
+
const ev = await createEV();
|
|
371
|
+
const w = new WDoubleSync({ endlessVector: ev, sync: makeSync() });
|
|
372
|
+
|
|
373
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
374
|
+
await sender.addFolder('empty');
|
|
375
|
+
const nested = await sender.addFolder('nested');
|
|
376
|
+
await nested.addFolder('also-empty');
|
|
377
|
+
await sender.addFile('file.txt', new Uint8Array([1, 2, 3]));
|
|
378
|
+
|
|
379
|
+
await w.initialize();
|
|
380
|
+
await w.push(sender);
|
|
381
|
+
|
|
382
|
+
const evReader = makeEV(ev.id);
|
|
383
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
384
|
+
const restored = await w2.restore();
|
|
385
|
+
|
|
386
|
+
const e1 = await restored.findByPath(['empty']);
|
|
387
|
+
expect(e1).toBeInstanceOf(DoubleSyncFolder);
|
|
388
|
+
const e2 = await restored.findByPath(['nested', 'also-empty']);
|
|
389
|
+
expect(e2).toBeInstanceOf(DoubleSyncFolder);
|
|
390
|
+
const f = await restored.findByPath(['file.txt']);
|
|
391
|
+
expect(f).toBeInstanceOf(DoubleSyncFile);
|
|
392
|
+
}, TX_TIMEOUT);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ─── larger tree with incremental edits ──────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
describe('larger tree with incremental edits', () => {
|
|
398
|
+
it('20 files across 4 folders, edit 3, restore matches', async () => {
|
|
399
|
+
const ev = await createEV();
|
|
400
|
+
const sync = makeSync();
|
|
401
|
+
const w = new WDoubleSync({ endlessVector: ev, sync });
|
|
402
|
+
|
|
403
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
404
|
+
const folders = [];
|
|
405
|
+
for (let f = 0; f < 4; f++) {
|
|
406
|
+
const folder = await sender.addFolder(`dir${f}`);
|
|
407
|
+
folders.push(folder);
|
|
408
|
+
for (let i = 0; i < 5; i++) {
|
|
409
|
+
await folder.addFile(`file${i}.bin`, seededBytes(512 + i * 128, f * 100 + i));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await w.initialize();
|
|
414
|
+
await w.push(sender);
|
|
415
|
+
|
|
416
|
+
// Edit 3 files in different folders
|
|
417
|
+
const f0 = await folders[0].findByPath(['file0.bin']);
|
|
418
|
+
await f0.setContent(seededBytes(1024, 999));
|
|
419
|
+
const f2 = await folders[2].findByPath(['file3.bin']);
|
|
420
|
+
await f2.setContent(seededBytes(1024, 998));
|
|
421
|
+
const f3 = await folders[3].findByPath(['file4.bin']);
|
|
422
|
+
await f3.setContent(seededBytes(1024, 997));
|
|
423
|
+
|
|
424
|
+
await w.push(sender);
|
|
425
|
+
|
|
426
|
+
// Restore
|
|
427
|
+
const evReader = makeEV(ev.id);
|
|
428
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
429
|
+
const restored = await w2.restore();
|
|
430
|
+
expect(await treesEqual(sender, restored)).toBe(true);
|
|
431
|
+
}, TX_TIMEOUT * 2);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ─── partial restore (specific version) ──────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
describe('partial restore', () => {
|
|
437
|
+
it('restore(N) stops at version N', async () => {
|
|
438
|
+
const ev = await createEV();
|
|
439
|
+
const sync = makeSync();
|
|
440
|
+
const w = new WDoubleSync({ endlessVector: ev, sync });
|
|
441
|
+
|
|
442
|
+
const sender = new DoubleSyncMemoryFolder('proj');
|
|
443
|
+
await w.initialize();
|
|
444
|
+
|
|
445
|
+
// v1
|
|
446
|
+
await sender.addFile('a.txt', seededBytes(512, 90));
|
|
447
|
+
await w.push(sender);
|
|
448
|
+
|
|
449
|
+
// v2
|
|
450
|
+
await sender.addFile('b.txt', seededBytes(512, 91));
|
|
451
|
+
await w.push(sender);
|
|
452
|
+
|
|
453
|
+
// v3
|
|
454
|
+
await sender.addFile('c.txt', seededBytes(512, 92));
|
|
455
|
+
await w.push(sender);
|
|
456
|
+
|
|
457
|
+
// restore(2) should have a.txt and b.txt but not c.txt
|
|
458
|
+
const evReader = makeEV(ev.id);
|
|
459
|
+
const w2 = new WDoubleSync({ endlessVector: evReader, sync: makeSync() });
|
|
460
|
+
const v2 = await w2.restore(2);
|
|
461
|
+
const v2tree = await collectTree(v2);
|
|
462
|
+
const v2paths = v2tree.map(f => f.path.join('/'));
|
|
463
|
+
expect(v2paths).toContain('a.txt');
|
|
464
|
+
expect(v2paths).toContain('b.txt');
|
|
465
|
+
expect(v2paths).not.toContain('c.txt');
|
|
466
|
+
|
|
467
|
+
// restore(1) should have only a.txt
|
|
468
|
+
const v1 = await w2.restore(1);
|
|
469
|
+
const v1tree = await collectTree(v1);
|
|
470
|
+
expect(v1tree.map(f => f.path.join('/'))).toEqual(['a.txt']);
|
|
471
|
+
}, TX_TIMEOUT * 3);
|
|
472
|
+
});
|