@shet-anirudh/crdt-sync-core 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/LICENSE +21 -0
- package/dist/__tests__/hlc.test.d.ts +2 -0
- package/dist/__tests__/hlc.test.d.ts.map +1 -0
- package/dist/__tests__/hlc.test.js +127 -0
- package/dist/__tests__/hlc.test.js.map +1 -0
- package/dist/__tests__/merge.test.d.ts +2 -0
- package/dist/__tests__/merge.test.d.ts.map +1 -0
- package/dist/__tests__/merge.test.js +316 -0
- package/dist/__tests__/merge.test.js.map +1 -0
- package/dist/hlc.d.ts +83 -0
- package/dist/hlc.d.ts.map +1 -0
- package/dist/hlc.js +127 -0
- package/dist/hlc.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/merge.d.ts +78 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +144 -0
- package/dist/merge.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 crdt-sync contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hlc.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/hlc.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { zeroHLC, tickHLC, mergeHLC, compareHLC, isAfter, hlcToJSON, hlcFromJSON, } from '../hlc.js';
|
|
3
|
+
describe('zeroHLC', () => {
|
|
4
|
+
it('returns a clock with wallTime=0 and logical=0', () => {
|
|
5
|
+
const z = zeroHLC();
|
|
6
|
+
expect(z.wallTime).toBe(0);
|
|
7
|
+
expect(z.logical).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
describe('tickHLC', () => {
|
|
11
|
+
it('uses now when now > local.wallTime', () => {
|
|
12
|
+
const local = { wallTime: 100, logical: 5 };
|
|
13
|
+
const result = tickHLC(local, 200);
|
|
14
|
+
expect(result.wallTime).toBe(200);
|
|
15
|
+
expect(result.logical).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
it('keeps local.wallTime when local.wallTime > now and increments logical', () => {
|
|
18
|
+
const local = { wallTime: 500, logical: 3 };
|
|
19
|
+
const result = tickHLC(local, 100);
|
|
20
|
+
expect(result.wallTime).toBe(500);
|
|
21
|
+
expect(result.logical).toBe(4);
|
|
22
|
+
});
|
|
23
|
+
it('increments logical when wallTime ties', () => {
|
|
24
|
+
const local = { wallTime: 300, logical: 7 };
|
|
25
|
+
const result = tickHLC(local, 300);
|
|
26
|
+
expect(result.wallTime).toBe(300);
|
|
27
|
+
expect(result.logical).toBe(8);
|
|
28
|
+
});
|
|
29
|
+
it('produces strictly increasing timestamps on successive ticks', () => {
|
|
30
|
+
let hlc = zeroHLC();
|
|
31
|
+
const now = 1_000_000;
|
|
32
|
+
for (let i = 0; i < 100; i++) {
|
|
33
|
+
const next = tickHLC(hlc, now);
|
|
34
|
+
expect(compareHLC(next, hlc)).toBeGreaterThan(0);
|
|
35
|
+
hlc = next;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('mergeHLC', () => {
|
|
40
|
+
it('takes the max wallTime', () => {
|
|
41
|
+
const local = { wallTime: 100, logical: 0 };
|
|
42
|
+
const remote = { wallTime: 200, logical: 0 };
|
|
43
|
+
const result = mergeHLC(local, remote, 50);
|
|
44
|
+
expect(result.wallTime).toBe(200);
|
|
45
|
+
});
|
|
46
|
+
it('increments logical when all three wallTimes tie', () => {
|
|
47
|
+
const local = { wallTime: 100, logical: 3 };
|
|
48
|
+
const remote = { wallTime: 100, logical: 5 };
|
|
49
|
+
const result = mergeHLC(local, remote, 100);
|
|
50
|
+
expect(result.wallTime).toBe(100);
|
|
51
|
+
expect(result.logical).toBe(6); // max(3, 5) + 1
|
|
52
|
+
});
|
|
53
|
+
it('uses local.logical + 1 when local.wallTime wins', () => {
|
|
54
|
+
const local = { wallTime: 300, logical: 4 };
|
|
55
|
+
const remote = { wallTime: 100, logical: 99 };
|
|
56
|
+
const result = mergeHLC(local, remote, 200);
|
|
57
|
+
expect(result.wallTime).toBe(300);
|
|
58
|
+
expect(result.logical).toBe(5);
|
|
59
|
+
});
|
|
60
|
+
it('uses remote.logical + 1 when remote.wallTime wins', () => {
|
|
61
|
+
const local = { wallTime: 100, logical: 2 };
|
|
62
|
+
const remote = { wallTime: 300, logical: 7 };
|
|
63
|
+
const result = mergeHLC(local, remote, 200);
|
|
64
|
+
expect(result.wallTime).toBe(300);
|
|
65
|
+
expect(result.logical).toBe(8);
|
|
66
|
+
});
|
|
67
|
+
it('throws when remote is implausibly far in the future (clock skew guard)', () => {
|
|
68
|
+
const local = { wallTime: 100, logical: 0 };
|
|
69
|
+
const remote = { wallTime: 100 + 61_000, logical: 0 }; // 61 seconds ahead
|
|
70
|
+
expect(() => mergeHLC(local, remote, 100)).toThrow(/clock skew too large/i);
|
|
71
|
+
});
|
|
72
|
+
it('does NOT throw when skew is within the allowed 60-second window', () => {
|
|
73
|
+
const local = { wallTime: 100, logical: 0 };
|
|
74
|
+
const remote = { wallTime: 100 + 59_000, logical: 0 }; // 59 seconds — fine
|
|
75
|
+
expect(() => mergeHLC(local, remote, 100)).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('compareHLC', () => {
|
|
79
|
+
it('returns negative when a < b by wallTime', () => {
|
|
80
|
+
const a = { wallTime: 100, logical: 5 };
|
|
81
|
+
const b = { wallTime: 200, logical: 0 };
|
|
82
|
+
expect(compareHLC(a, b)).toBeLessThan(0);
|
|
83
|
+
});
|
|
84
|
+
it('returns positive when a > b by wallTime', () => {
|
|
85
|
+
const a = { wallTime: 200, logical: 0 };
|
|
86
|
+
const b = { wallTime: 100, logical: 5 };
|
|
87
|
+
expect(compareHLC(a, b)).toBeGreaterThan(0);
|
|
88
|
+
});
|
|
89
|
+
it('breaks ties by logical counter', () => {
|
|
90
|
+
const a = { wallTime: 100, logical: 3 };
|
|
91
|
+
const b = { wallTime: 100, logical: 7 };
|
|
92
|
+
expect(compareHLC(a, b)).toBeLessThan(0);
|
|
93
|
+
expect(compareHLC(b, a)).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
it('returns 0 for identical HLCs', () => {
|
|
96
|
+
const a = { wallTime: 100, logical: 5 };
|
|
97
|
+
expect(compareHLC(a, a)).toBe(0);
|
|
98
|
+
expect(compareHLC({ ...a }, { ...a })).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('isAfter', () => {
|
|
102
|
+
it('returns true when a is strictly after b', () => {
|
|
103
|
+
const a = { wallTime: 200, logical: 0 };
|
|
104
|
+
const b = { wallTime: 100, logical: 0 };
|
|
105
|
+
expect(isAfter(a, b)).toBe(true);
|
|
106
|
+
expect(isAfter(b, a)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
it('returns false when HLCs are equal', () => {
|
|
109
|
+
const a = { wallTime: 100, logical: 5 };
|
|
110
|
+
expect(isAfter(a, a)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('hlcToJSON / hlcFromJSON', () => {
|
|
114
|
+
it('round-trips correctly', () => {
|
|
115
|
+
const original = { wallTime: 1_700_000_000_000, logical: 42 };
|
|
116
|
+
const json = hlcToJSON(original);
|
|
117
|
+
const restored = hlcFromJSON(json);
|
|
118
|
+
expect(restored).toEqual(original);
|
|
119
|
+
});
|
|
120
|
+
it('throws on invalid JSON', () => {
|
|
121
|
+
expect(() => hlcFromJSON(null)).toThrow();
|
|
122
|
+
expect(() => hlcFromJSON({ wallTime: 'bad', logical: 0 })).toThrow();
|
|
123
|
+
expect(() => hlcFromJSON({ wallTime: 100 })).toThrow();
|
|
124
|
+
expect(() => hlcFromJSON(42)).toThrow();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
//# sourceMappingURL=hlc.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hlc.test.js","sourceRoot":"","sources":["../../src/__tests__/hlc.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/merge.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { createRecord, mergeRecords, mergeFields, applyLocalChange, deleteField, isFieldAlive, liveFields, } from '../merge.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Arbitraries (fast-check generators)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// JS object prototype property names that must not be used as CRDT field keys
|
|
8
|
+
// (they would shadow Object.prototype methods on non-null-prototype objects in
|
|
9
|
+
// serialized/deserialized forms, or confuse JSON.stringify sorting).
|
|
10
|
+
const RESERVED_KEYS = new Set([
|
|
11
|
+
'constructor', 'toString', 'valueOf', 'hasOwnProperty',
|
|
12
|
+
'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
|
|
13
|
+
'__proto__', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__',
|
|
14
|
+
]);
|
|
15
|
+
const arbDeviceId = fc.string({ minLength: 1, maxLength: 16 });
|
|
16
|
+
const arbFieldValue = fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null));
|
|
17
|
+
const arbHLC = fc
|
|
18
|
+
.tuple(fc.nat({ max: 2_000_000_000_000 }), fc.nat({ max: 1000 }))
|
|
19
|
+
.map(([wallTime, logical]) => ({ wallTime, logical }));
|
|
20
|
+
const arbFieldEntry = fc
|
|
21
|
+
.tuple(arbFieldValue, arbHLC, arbDeviceId)
|
|
22
|
+
.map(([value, hlc, origin]) => ({ value, hlc, origin }));
|
|
23
|
+
const arbTombstone = fc
|
|
24
|
+
.tuple(arbHLC, arbDeviceId)
|
|
25
|
+
.map(([hlc, origin]) => ({ value: null, hlc, origin, deleted: true }));
|
|
26
|
+
const arbAnyFieldEntry = fc.oneof(arbFieldEntry, arbTombstone);
|
|
27
|
+
/** Generate a CRDTRecord with 0–5 fields, avoiding reserved JS property names. */
|
|
28
|
+
const arbCRDTRecord = fc
|
|
29
|
+
.dictionary(fc.string({ minLength: 1, maxLength: 8 }).filter((k) => !RESERVED_KEYS.has(k)), arbAnyFieldEntry, { minKeys: 0, maxKeys: 5 })
|
|
30
|
+
.map((obj) => Object.assign(Object.create(null), obj));
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helper: deep equality check for CRDTRecords
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function recordsEqual(a, b) {
|
|
35
|
+
return JSON.stringify(sortedRecord(a)) === JSON.stringify(sortedRecord(b));
|
|
36
|
+
}
|
|
37
|
+
function sortedRecord(r) {
|
|
38
|
+
// Use a regular object (with prototype) here — only for JSON serialization
|
|
39
|
+
// comparison, not for CRDT operations.
|
|
40
|
+
const sorted = {};
|
|
41
|
+
for (const key of Object.keys(r).sort()) {
|
|
42
|
+
sorted[key] = r[key];
|
|
43
|
+
}
|
|
44
|
+
return sorted;
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// mergeFields — unit tests
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
describe('mergeFields', () => {
|
|
50
|
+
it('returns the entry with the higher HLC wallTime', () => {
|
|
51
|
+
const older = { value: 'old', hlc: { wallTime: 100, logical: 0 }, origin: 'a' };
|
|
52
|
+
const newer = { value: 'new', hlc: { wallTime: 200, logical: 0 }, origin: 'b' };
|
|
53
|
+
expect(mergeFields(older, newer)).toBe(newer);
|
|
54
|
+
expect(mergeFields(newer, older)).toBe(newer);
|
|
55
|
+
});
|
|
56
|
+
it('uses higher logical counter on wallTime tie', () => {
|
|
57
|
+
const a = { value: 'a', hlc: { wallTime: 100, logical: 5 }, origin: 'x' };
|
|
58
|
+
const b = { value: 'b', hlc: { wallTime: 100, logical: 3 }, origin: 'y' };
|
|
59
|
+
expect(mergeFields(a, b)).toBe(a);
|
|
60
|
+
expect(mergeFields(b, a)).toBe(a);
|
|
61
|
+
});
|
|
62
|
+
it('tie-breaks on DeviceId when HLCs are identical (lexicographic, larger wins)', () => {
|
|
63
|
+
const a = { value: 'a', hlc: { wallTime: 100, logical: 0 }, origin: 'zzz' };
|
|
64
|
+
const b = { value: 'b', hlc: { wallTime: 100, logical: 0 }, origin: 'aaa' };
|
|
65
|
+
expect(mergeFields(a, b)).toBe(a); // 'zzz' > 'aaa'
|
|
66
|
+
expect(mergeFields(b, a)).toBe(a);
|
|
67
|
+
});
|
|
68
|
+
it('returns the same entry when both sides are identical (idempotence)', () => {
|
|
69
|
+
const entry = { value: 42, hlc: { wallTime: 100, logical: 0 }, origin: 'dev' };
|
|
70
|
+
const result = mergeFields(entry, { ...entry });
|
|
71
|
+
expect(result).toEqual(entry);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// mergeRecords — unit tests
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
describe('mergeRecords', () => {
|
|
78
|
+
it('returns union of fields when there is no overlap', () => {
|
|
79
|
+
const a = {
|
|
80
|
+
foo: { value: 1, hlc: { wallTime: 100, logical: 0 }, origin: 'a' },
|
|
81
|
+
};
|
|
82
|
+
const b = {
|
|
83
|
+
bar: { value: 2, hlc: { wallTime: 100, logical: 0 }, origin: 'b' },
|
|
84
|
+
};
|
|
85
|
+
const merged = mergeRecords(a, b);
|
|
86
|
+
expect(merged['foo']?.value).toBe(1);
|
|
87
|
+
expect(merged['bar']?.value).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
it('resolves conflicting fields to the newer HLC', () => {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const a = {
|
|
92
|
+
title: { value: 'old', hlc: { wallTime: now - 1000, logical: 0 }, origin: 'a' },
|
|
93
|
+
};
|
|
94
|
+
const b = {
|
|
95
|
+
title: { value: 'new', hlc: { wallTime: now, logical: 0 }, origin: 'b' },
|
|
96
|
+
};
|
|
97
|
+
const merged = mergeRecords(a, b);
|
|
98
|
+
expect(merged['title']?.value).toBe('new');
|
|
99
|
+
});
|
|
100
|
+
it('does not mutate the input records', () => {
|
|
101
|
+
const a = {
|
|
102
|
+
x: { value: 1, hlc: { wallTime: 100, logical: 0 }, origin: 'a' },
|
|
103
|
+
};
|
|
104
|
+
const b = {
|
|
105
|
+
x: { value: 2, hlc: { wallTime: 200, logical: 0 }, origin: 'b' },
|
|
106
|
+
};
|
|
107
|
+
const aCopy = JSON.stringify(a);
|
|
108
|
+
const bCopy = JSON.stringify(b);
|
|
109
|
+
mergeRecords(a, b);
|
|
110
|
+
expect(JSON.stringify(a)).toBe(aCopy);
|
|
111
|
+
expect(JSON.stringify(b)).toBe(bCopy);
|
|
112
|
+
});
|
|
113
|
+
it('handles empty records', () => {
|
|
114
|
+
const a = {};
|
|
115
|
+
const b = { x: { value: 1, hlc: { wallTime: 100, logical: 0 }, origin: 'b' } };
|
|
116
|
+
expect(mergeRecords(a, b)).toEqual(b);
|
|
117
|
+
expect(mergeRecords(b, a)).toEqual(b);
|
|
118
|
+
expect(mergeRecords(a, a)).toEqual({});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// CRDT Convergence Properties (property-based with fast-check)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
describe('Convergence properties', () => {
|
|
125
|
+
describe('Commutativity: merge(A, B) ≡ merge(B, A)', () => {
|
|
126
|
+
it('holds for arbitrary records', () => {
|
|
127
|
+
fc.assert(fc.property(arbCRDTRecord, arbCRDTRecord, (a, b) => {
|
|
128
|
+
const ab = mergeRecords(a, b);
|
|
129
|
+
const ba = mergeRecords(b, a);
|
|
130
|
+
return recordsEqual(ab, ba);
|
|
131
|
+
}), { numRuns: 500 });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('Associativity: merge(merge(A,B),C) ≡ merge(A, merge(B,C))', () => {
|
|
135
|
+
it('holds for arbitrary records', () => {
|
|
136
|
+
fc.assert(fc.property(arbCRDTRecord, arbCRDTRecord, arbCRDTRecord, (a, b, c) => {
|
|
137
|
+
const left = mergeRecords(mergeRecords(a, b), c);
|
|
138
|
+
const right = mergeRecords(a, mergeRecords(b, c));
|
|
139
|
+
return recordsEqual(left, right);
|
|
140
|
+
}), { numRuns: 300 });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('Idempotence: merge(A, A) ≡ A', () => {
|
|
144
|
+
it('holds for arbitrary records', () => {
|
|
145
|
+
fc.assert(fc.property(arbCRDTRecord, (a) => {
|
|
146
|
+
const merged = mergeRecords(a, a);
|
|
147
|
+
return recordsEqual(merged, a);
|
|
148
|
+
}), { numRuns: 500 });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe('Convergence across arbitrary edit sequences', () => {
|
|
152
|
+
it('two devices that exchange all messages always converge', () => {
|
|
153
|
+
fc.assert(fc.property(fc.array(fc.tuple(arbDeviceId, fc.string({ minLength: 1, maxLength: 8 }), arbFieldValue), {
|
|
154
|
+
minLength: 1,
|
|
155
|
+
maxLength: 20,
|
|
156
|
+
}), (edits) => {
|
|
157
|
+
let recordA = createRecord();
|
|
158
|
+
let recordB = createRecord();
|
|
159
|
+
let now = 1_000_000;
|
|
160
|
+
// Apply edits to alternating devices
|
|
161
|
+
edits.forEach(([_device, field, value], i) => {
|
|
162
|
+
now += 10;
|
|
163
|
+
if (i % 2 === 0) {
|
|
164
|
+
recordA = applyLocalChange(recordA, field, value, 'device-a', now);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
recordB = applyLocalChange(recordB, field, value, 'device-b', now);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Both devices exchange and merge
|
|
171
|
+
const mergedByA = mergeRecords(recordA, recordB);
|
|
172
|
+
const mergedByB = mergeRecords(recordB, recordA);
|
|
173
|
+
return recordsEqual(mergedByA, mergedByB);
|
|
174
|
+
}), { numRuns: 200 });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// applyLocalChange — unit tests
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
describe('applyLocalChange', () => {
|
|
182
|
+
it('creates a new field with an HLC above zero', () => {
|
|
183
|
+
const record = createRecord();
|
|
184
|
+
const result = applyLocalChange(record, 'title', 'hello', 'dev-a', 1_000_000);
|
|
185
|
+
expect(result['title']?.value).toBe('hello');
|
|
186
|
+
expect(result['title']?.origin).toBe('dev-a');
|
|
187
|
+
expect(result['title']?.hlc.wallTime).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
it('increments HLC on successive writes to the same field', () => {
|
|
190
|
+
let record = createRecord();
|
|
191
|
+
const now = 1_000_000;
|
|
192
|
+
record = applyLocalChange(record, 'x', 1, 'dev-a', now);
|
|
193
|
+
const hlc1 = record['x'].hlc;
|
|
194
|
+
record = applyLocalChange(record, 'x', 2, 'dev-a', now); // same wall time
|
|
195
|
+
const hlc2 = record['x'].hlc;
|
|
196
|
+
expect(hlc2.logical).toBeGreaterThan(hlc1.logical);
|
|
197
|
+
});
|
|
198
|
+
it('does not mutate the original record', () => {
|
|
199
|
+
const record = createRecord();
|
|
200
|
+
const snapshot = JSON.stringify(record);
|
|
201
|
+
applyLocalChange(record, 'foo', 'bar', 'dev', Date.now());
|
|
202
|
+
expect(JSON.stringify(record)).toBe(snapshot);
|
|
203
|
+
});
|
|
204
|
+
it('a later write from device-b beats an earlier write from device-a', () => {
|
|
205
|
+
let recordA = createRecord();
|
|
206
|
+
let recordB = createRecord();
|
|
207
|
+
recordA = applyLocalChange(recordA, 'title', 'from A', 'device-a', 1000);
|
|
208
|
+
recordB = applyLocalChange(recordB, 'title', 'from B', 'device-b', 2000); // later
|
|
209
|
+
const merged = mergeRecords(recordA, recordB);
|
|
210
|
+
expect(merged['title']?.value).toBe('from B');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// deleteField — tombstones
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
describe('deleteField', () => {
|
|
217
|
+
it('tombstones an existing field', () => {
|
|
218
|
+
let record = createRecord();
|
|
219
|
+
record = applyLocalChange(record, 'title', 'hello', 'dev', 1000);
|
|
220
|
+
record = deleteField(record, 'title', 'dev', 2000);
|
|
221
|
+
expect(record['title']?.value).toBeNull();
|
|
222
|
+
expect(record['title']?.deleted).toBe(true);
|
|
223
|
+
expect(isFieldAlive(record, 'title')).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
it('tombstone beats an older write on merge', () => {
|
|
226
|
+
let recordA = createRecord();
|
|
227
|
+
let recordB = createRecord();
|
|
228
|
+
recordA = applyLocalChange(recordA, 'note', 'important!', 'device-a', 1000);
|
|
229
|
+
recordB = deleteField(recordB, 'note', 'device-b', 2000); // later delete
|
|
230
|
+
const merged = mergeRecords(recordA, recordB);
|
|
231
|
+
expect(merged['note']?.deleted).toBe(true);
|
|
232
|
+
expect(isFieldAlive(merged, 'note')).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
it('older tombstone does NOT override a newer write', () => {
|
|
235
|
+
let recordA = createRecord();
|
|
236
|
+
let recordB = createRecord();
|
|
237
|
+
recordB = deleteField(recordB, 'note', 'device-b', 1000); // older delete
|
|
238
|
+
recordA = applyLocalChange(recordA, 'note', 'resurrected', 'device-a', 2000); // newer write
|
|
239
|
+
const merged = mergeRecords(recordA, recordB);
|
|
240
|
+
expect(merged['note']?.value).toBe('resurrected');
|
|
241
|
+
expect(merged['note']?.deleted).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Utility helpers
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
describe('isFieldAlive / liveFields', () => {
|
|
248
|
+
it('returns false for tombstoned fields', () => {
|
|
249
|
+
let record = createRecord();
|
|
250
|
+
record = applyLocalChange(record, 'x', 1, 'dev', 1000);
|
|
251
|
+
record = deleteField(record, 'x', 'dev', 2000);
|
|
252
|
+
expect(isFieldAlive(record, 'x')).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
it('returns true for a live field', () => {
|
|
255
|
+
let record = createRecord();
|
|
256
|
+
record = applyLocalChange(record, 'x', 1, 'dev', 1000);
|
|
257
|
+
expect(isFieldAlive(record, 'x')).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
it('liveFields excludes tombstones', () => {
|
|
260
|
+
let record = createRecord();
|
|
261
|
+
record = applyLocalChange(record, 'a', 1, 'dev', 1000);
|
|
262
|
+
record = applyLocalChange(record, 'b', 2, 'dev', 1000);
|
|
263
|
+
record = deleteField(record, 'b', 'dev', 2000);
|
|
264
|
+
expect(liveFields(record)).toEqual(['a']);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Edge cases
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
describe('Edge cases', () => {
|
|
271
|
+
it('handles out-of-order message delivery (offline device rejoining)', () => {
|
|
272
|
+
let online = createRecord();
|
|
273
|
+
let offline = createRecord();
|
|
274
|
+
// Offline device writes at t=1000 while disconnected
|
|
275
|
+
offline = applyLocalChange(offline, 'status', 'offline-value', 'device-offline', 1000);
|
|
276
|
+
// Online device writes at t=2000 (later)
|
|
277
|
+
online = applyLocalChange(online, 'status', 'online-value', 'device-online', 2000);
|
|
278
|
+
// Offline device reconnects and exchanges state
|
|
279
|
+
const mergedAtOnline = mergeRecords(online, offline);
|
|
280
|
+
const mergedAtOffline = mergeRecords(offline, online);
|
|
281
|
+
// Both sides converge to the same value
|
|
282
|
+
expect(mergedAtOnline['status']?.value).toBe(mergedAtOffline['status']?.value);
|
|
283
|
+
// The later wall-clock write wins
|
|
284
|
+
expect(mergedAtOnline['status']?.value).toBe('online-value');
|
|
285
|
+
});
|
|
286
|
+
it('handles concurrent edits to the same field (deterministic tie-break)', () => {
|
|
287
|
+
let recordA = createRecord();
|
|
288
|
+
let recordB = createRecord();
|
|
289
|
+
const SAME_TIME = 1_000_000;
|
|
290
|
+
// Both devices write at exactly the same HLC wallTime + logical
|
|
291
|
+
// (This is the most adversarial case)
|
|
292
|
+
recordA = applyLocalChange(recordA, 'field', 'value-from-A', 'device-zzz', SAME_TIME);
|
|
293
|
+
recordB = applyLocalChange(recordB, 'field', 'value-from-B', 'device-aaa', SAME_TIME);
|
|
294
|
+
// Force identical HLCs to test pure DeviceId tie-break
|
|
295
|
+
const hlc = { wallTime: SAME_TIME, logical: 1 };
|
|
296
|
+
recordA['field'] = { value: 'value-from-A', hlc, origin: 'device-zzz' };
|
|
297
|
+
recordB['field'] = { value: 'value-from-B', hlc, origin: 'device-aaa' };
|
|
298
|
+
const mergedAB = mergeRecords(recordA, recordB);
|
|
299
|
+
const mergedBA = mergeRecords(recordB, recordA);
|
|
300
|
+
// Must converge to the same value (commutativity)
|
|
301
|
+
expect(mergedAB['field']?.value).toBe(mergedBA['field']?.value);
|
|
302
|
+
// 'device-zzz' > 'device-aaa' lexicographically → A's value wins
|
|
303
|
+
expect(mergedAB['field']?.value).toBe('value-from-A');
|
|
304
|
+
});
|
|
305
|
+
it('field deleted on one device does not appear on merge', () => {
|
|
306
|
+
let a = createRecord();
|
|
307
|
+
let b = createRecord();
|
|
308
|
+
a = applyLocalChange(a, 'temp', 'to be deleted', 'dev-a', 1000);
|
|
309
|
+
b = applyLocalChange(b, 'temp', 'to be deleted', 'dev-b', 1000);
|
|
310
|
+
// Device A deletes the field
|
|
311
|
+
a = deleteField(a, 'temp', 'dev-a', 2000);
|
|
312
|
+
const merged = mergeRecords(a, b);
|
|
313
|
+
expect(isFieldAlive(merged, 'temp')).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
//# sourceMappingURL=merge.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge.test.js","sourceRoot":"","sources":["../../src/__tests__/merge.test.ts"],"names":[],"mappings":"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"}
|
package/dist/hlc.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Logical Clock (HLC) implementation.
|
|
3
|
+
*
|
|
4
|
+
* Based on: "Logical Physical Clocks and Consistent Snapshots in Globally
|
|
5
|
+
* Distributed Databases" — Kulkarni et al., 2014.
|
|
6
|
+
* https://cse.buffalo.edu/tech-reports/2014-04.pdf
|
|
7
|
+
*
|
|
8
|
+
* An HLC timestamp is a pair (wallTime, logical):
|
|
9
|
+
* - wallTime: milliseconds since epoch (wall-clock)
|
|
10
|
+
* - logical: monotone counter, incremented when wallTime ties
|
|
11
|
+
*
|
|
12
|
+
* This gives us:
|
|
13
|
+
* - Total order across all devices
|
|
14
|
+
* - Correlation with real time (unlike pure Lamport clocks)
|
|
15
|
+
* - O(1) message overhead (unlike vector clocks which are O(N))
|
|
16
|
+
* - Resilience to clock skew (logical counter absorbs it)
|
|
17
|
+
*/
|
|
18
|
+
import type { HLC } from './types.js';
|
|
19
|
+
/**
|
|
20
|
+
* Create a zero-value HLC (the smallest possible timestamp).
|
|
21
|
+
* Useful as an initial value before any events.
|
|
22
|
+
*/
|
|
23
|
+
export declare function zeroHLC(): HLC;
|
|
24
|
+
/**
|
|
25
|
+
* Advance the HLC for a local event.
|
|
26
|
+
*
|
|
27
|
+
* Algorithm (send/local-event rule from the HLC paper):
|
|
28
|
+
* l' = max(l.wallTime, now)
|
|
29
|
+
* c' = (l' == l.wallTime) ? l.logical + 1 : 0
|
|
30
|
+
*
|
|
31
|
+
* @param local - Current local HLC state.
|
|
32
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
33
|
+
* @returns A new HLC representing the event timestamp.
|
|
34
|
+
* @throws If `now` is more than MAX_CLOCK_SKEW_MS behind local.wallTime.
|
|
35
|
+
*/
|
|
36
|
+
export declare function tickHLC(local: HLC, now: number): HLC;
|
|
37
|
+
/**
|
|
38
|
+
* Merge two HLC timestamps, producing the new local state after receiving
|
|
39
|
+
* a remote event (the receive rule from the HLC paper).
|
|
40
|
+
*
|
|
41
|
+
* Algorithm:
|
|
42
|
+
* l' = max(local.wallTime, remote.wallTime, now)
|
|
43
|
+
* c' = if all three tie → local.logical + 1
|
|
44
|
+
* else if local wins → local.logical + 1
|
|
45
|
+
* else if remote wins → remote.logical + 1
|
|
46
|
+
* else → 0
|
|
47
|
+
*
|
|
48
|
+
* @param local - Current local HLC state.
|
|
49
|
+
* @param remote - HLC from the received remote message.
|
|
50
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
51
|
+
* @returns Updated local HLC after absorbing the remote event.
|
|
52
|
+
* @throws If remote.wallTime is implausibly far in the future (clock skew guard).
|
|
53
|
+
*/
|
|
54
|
+
export declare function mergeHLC(local: HLC, remote: HLC, now: number): HLC;
|
|
55
|
+
/**
|
|
56
|
+
* Compare two HLC timestamps for total ordering.
|
|
57
|
+
*
|
|
58
|
+
* Returns:
|
|
59
|
+
* - negative if `a < b`
|
|
60
|
+
* - 0 if `a == b`
|
|
61
|
+
* - positive if `a > b`
|
|
62
|
+
*
|
|
63
|
+
* Wall-clock time takes priority; logical counter breaks ties.
|
|
64
|
+
*/
|
|
65
|
+
export declare function compareHLC(a: HLC, b: HLC): number;
|
|
66
|
+
/**
|
|
67
|
+
* Returns true if `a` is strictly later than `b`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function isAfter(a: HLC, b: HLC): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Serialize an HLC to a plain JSON-compatible object.
|
|
72
|
+
* Suitable for MessagePack or JSON wire formats.
|
|
73
|
+
*/
|
|
74
|
+
export declare function hlcToJSON(hlc: HLC): {
|
|
75
|
+
wallTime: number;
|
|
76
|
+
logical: number;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Deserialize an HLC from a plain object (e.g., parsed from MessagePack).
|
|
80
|
+
* @throws If the object is missing required fields or has invalid types.
|
|
81
|
+
*/
|
|
82
|
+
export declare function hlcFromJSON(raw: unknown): HLC;
|
|
83
|
+
//# sourceMappingURL=hlc.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hlc.d.ts","sourceRoot":"","sources":["../src/hlc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAKtC;;;GAGG;AACH,wBAAgB,OAAO,IAAI,GAAG,CAE7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,GAAG,GAAG,CAIpD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,GAAG,GAAG,CAsBlE;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,GAAG,MAAM,CAKjD;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,GAAG,OAAO,CAE/C;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAEzE;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAa7C"}
|
package/dist/hlc.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Logical Clock (HLC) implementation.
|
|
3
|
+
*
|
|
4
|
+
* Based on: "Logical Physical Clocks and Consistent Snapshots in Globally
|
|
5
|
+
* Distributed Databases" — Kulkarni et al., 2014.
|
|
6
|
+
* https://cse.buffalo.edu/tech-reports/2014-04.pdf
|
|
7
|
+
*
|
|
8
|
+
* An HLC timestamp is a pair (wallTime, logical):
|
|
9
|
+
* - wallTime: milliseconds since epoch (wall-clock)
|
|
10
|
+
* - logical: monotone counter, incremented when wallTime ties
|
|
11
|
+
*
|
|
12
|
+
* This gives us:
|
|
13
|
+
* - Total order across all devices
|
|
14
|
+
* - Correlation with real time (unlike pure Lamport clocks)
|
|
15
|
+
* - O(1) message overhead (unlike vector clocks which are O(N))
|
|
16
|
+
* - Resilience to clock skew (logical counter absorbs it)
|
|
17
|
+
*/
|
|
18
|
+
/** Maximum allowed clock skew in milliseconds (prevent runaway logical counters). */
|
|
19
|
+
const MAX_CLOCK_SKEW_MS = 60_000; // 1 minute
|
|
20
|
+
/**
|
|
21
|
+
* Create a zero-value HLC (the smallest possible timestamp).
|
|
22
|
+
* Useful as an initial value before any events.
|
|
23
|
+
*/
|
|
24
|
+
export function zeroHLC() {
|
|
25
|
+
return { wallTime: 0, logical: 0 };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Advance the HLC for a local event.
|
|
29
|
+
*
|
|
30
|
+
* Algorithm (send/local-event rule from the HLC paper):
|
|
31
|
+
* l' = max(l.wallTime, now)
|
|
32
|
+
* c' = (l' == l.wallTime) ? l.logical + 1 : 0
|
|
33
|
+
*
|
|
34
|
+
* @param local - Current local HLC state.
|
|
35
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
36
|
+
* @returns A new HLC representing the event timestamp.
|
|
37
|
+
* @throws If `now` is more than MAX_CLOCK_SKEW_MS behind local.wallTime.
|
|
38
|
+
*/
|
|
39
|
+
export function tickHLC(local, now) {
|
|
40
|
+
const wallTime = Math.max(local.wallTime, now);
|
|
41
|
+
const logical = wallTime === local.wallTime ? local.logical + 1 : 0;
|
|
42
|
+
return { wallTime, logical };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Merge two HLC timestamps, producing the new local state after receiving
|
|
46
|
+
* a remote event (the receive rule from the HLC paper).
|
|
47
|
+
*
|
|
48
|
+
* Algorithm:
|
|
49
|
+
* l' = max(local.wallTime, remote.wallTime, now)
|
|
50
|
+
* c' = if all three tie → local.logical + 1
|
|
51
|
+
* else if local wins → local.logical + 1
|
|
52
|
+
* else if remote wins → remote.logical + 1
|
|
53
|
+
* else → 0
|
|
54
|
+
*
|
|
55
|
+
* @param local - Current local HLC state.
|
|
56
|
+
* @param remote - HLC from the received remote message.
|
|
57
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
58
|
+
* @returns Updated local HLC after absorbing the remote event.
|
|
59
|
+
* @throws If remote.wallTime is implausibly far in the future (clock skew guard).
|
|
60
|
+
*/
|
|
61
|
+
export function mergeHLC(local, remote, now) {
|
|
62
|
+
if (remote.wallTime - now > MAX_CLOCK_SKEW_MS) {
|
|
63
|
+
throw new Error(`HLC clock skew too large: remote.wallTime=${remote.wallTime}, now=${now}, ` +
|
|
64
|
+
`skew=${remote.wallTime - now}ms (max ${MAX_CLOCK_SKEW_MS}ms)`);
|
|
65
|
+
}
|
|
66
|
+
const wallTime = Math.max(local.wallTime, remote.wallTime, now);
|
|
67
|
+
let logical;
|
|
68
|
+
if (wallTime === local.wallTime && wallTime === remote.wallTime) {
|
|
69
|
+
logical = Math.max(local.logical, remote.logical) + 1;
|
|
70
|
+
}
|
|
71
|
+
else if (wallTime === local.wallTime) {
|
|
72
|
+
logical = local.logical + 1;
|
|
73
|
+
}
|
|
74
|
+
else if (wallTime === remote.wallTime) {
|
|
75
|
+
logical = remote.logical + 1;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
logical = 0;
|
|
79
|
+
}
|
|
80
|
+
return { wallTime, logical };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Compare two HLC timestamps for total ordering.
|
|
84
|
+
*
|
|
85
|
+
* Returns:
|
|
86
|
+
* - negative if `a < b`
|
|
87
|
+
* - 0 if `a == b`
|
|
88
|
+
* - positive if `a > b`
|
|
89
|
+
*
|
|
90
|
+
* Wall-clock time takes priority; logical counter breaks ties.
|
|
91
|
+
*/
|
|
92
|
+
export function compareHLC(a, b) {
|
|
93
|
+
if (a.wallTime !== b.wallTime) {
|
|
94
|
+
return a.wallTime - b.wallTime;
|
|
95
|
+
}
|
|
96
|
+
return a.logical - b.logical;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Returns true if `a` is strictly later than `b`.
|
|
100
|
+
*/
|
|
101
|
+
export function isAfter(a, b) {
|
|
102
|
+
return compareHLC(a, b) > 0;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Serialize an HLC to a plain JSON-compatible object.
|
|
106
|
+
* Suitable for MessagePack or JSON wire formats.
|
|
107
|
+
*/
|
|
108
|
+
export function hlcToJSON(hlc) {
|
|
109
|
+
return { wallTime: hlc.wallTime, logical: hlc.logical };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Deserialize an HLC from a plain object (e.g., parsed from MessagePack).
|
|
113
|
+
* @throws If the object is missing required fields or has invalid types.
|
|
114
|
+
*/
|
|
115
|
+
export function hlcFromJSON(raw) {
|
|
116
|
+
if (typeof raw !== 'object' ||
|
|
117
|
+
raw === null ||
|
|
118
|
+
!('wallTime' in raw) ||
|
|
119
|
+
!('logical' in raw) ||
|
|
120
|
+
typeof raw['wallTime'] !== 'number' ||
|
|
121
|
+
typeof raw['logical'] !== 'number') {
|
|
122
|
+
throw new Error(`Invalid HLC JSON: ${JSON.stringify(raw)}`);
|
|
123
|
+
}
|
|
124
|
+
const { wallTime, logical } = raw;
|
|
125
|
+
return { wallTime, logical };
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=hlc.js.map
|
package/dist/hlc.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hlc.js","sourceRoot":"","sources":["../src/hlc.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shet-anirudh/crdt-sync-core — Public API
|
|
3
|
+
*
|
|
4
|
+
* Entry point for the CRDT sync engine core package.
|
|
5
|
+
*/
|
|
6
|
+
export type { DeviceId, FieldValue, FieldEntry, CRDTRecord, HLC } from './types.js';
|
|
7
|
+
export { zeroHLC, tickHLC, mergeHLC, compareHLC, isAfter, hlcToJSON, hlcFromJSON, } from './hlc.js';
|
|
8
|
+
export { mergeFields, mergeRecords, applyLocalChange, deleteField, createRecord, isFieldAlive, liveFields, } from './merge.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAGpF,OAAO,EACL,OAAO,EACP,OAAO,EACP,QAAQ,EACR,UAAU,EACV,OAAO,EACP,SAAS,EACT,WAAW,GACZ,MAAM,UAAU,CAAC;AAGlB,OAAO,EACL,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,UAAU,GACX,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shet-anirudh/crdt-sync-core — Public API
|
|
3
|
+
*
|
|
4
|
+
* Entry point for the CRDT sync engine core package.
|
|
5
|
+
*/
|
|
6
|
+
// Hybrid Logical Clock
|
|
7
|
+
export { zeroHLC, tickHLC, mergeHLC, compareHLC, isAfter, hlcToJSON, hlcFromJSON, } from './hlc.js';
|
|
8
|
+
// CRDT Merge & Mutations
|
|
9
|
+
export { mergeFields, mergeRecords, applyLocalChange, deleteField, createRecord, isFieldAlive, liveFields, } from './merge.js';
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,uBAAuB;AACvB,OAAO,EACL,OAAO,EACP,OAAO,EACP,QAAQ,EACR,UAAU,EACV,OAAO,EACP,SAAS,EACT,WAAW,GACZ,MAAM,UAAU,CAAC;AAElB,yBAAyB;AACzB,OAAO,EACL,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,UAAU,GACX,MAAM,YAAY,CAAC"}
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT merge logic — the heart of the sync engine.
|
|
3
|
+
*
|
|
4
|
+
* All functions are PURE (no side effects, no async, no I/O).
|
|
5
|
+
* This makes them trivially testable and composable.
|
|
6
|
+
*
|
|
7
|
+
* Merge strategy: Last-Write-Wins Map (LWW-Map)
|
|
8
|
+
* - Each field independently stores its last-written value with an HLC timestamp.
|
|
9
|
+
* - On merge, the field with the higher HLC timestamp wins.
|
|
10
|
+
* - Tie-break: lexicographic comparison of DeviceId (deterministic, no coordination).
|
|
11
|
+
*
|
|
12
|
+
* Convergence properties (verified by property-based tests in merge.test.ts):
|
|
13
|
+
* - Commutativity: merge(A, B) ≡ merge(B, A)
|
|
14
|
+
* - Associativity: merge(merge(A,B),C) ≡ merge(A, merge(B,C))
|
|
15
|
+
* - Idempotence: merge(A, A) ≡ A
|
|
16
|
+
*/
|
|
17
|
+
import type { CRDTRecord, DeviceId, FieldEntry, FieldValue } from './types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Merge two FieldEntry values, returning the winner.
|
|
20
|
+
*
|
|
21
|
+
* Resolution order:
|
|
22
|
+
* 1. Higher HLC timestamp wins.
|
|
23
|
+
* 2. If HLC timestamps are equal, the lexicographically larger DeviceId wins.
|
|
24
|
+
* (Deterministic total order — all devices converge to the same result.)
|
|
25
|
+
*/
|
|
26
|
+
export declare function mergeFields<T extends FieldValue>(local: FieldEntry<T>, remote: FieldEntry<T>): FieldEntry<T>;
|
|
27
|
+
/**
|
|
28
|
+
* Merge two CRDTRecords field by field.
|
|
29
|
+
*
|
|
30
|
+
* Fields that exist only in one record are included as-is (union semantics).
|
|
31
|
+
* Fields in both records are resolved with mergeFields.
|
|
32
|
+
*
|
|
33
|
+
* @returns A new CRDTRecord (does not mutate inputs).
|
|
34
|
+
*/
|
|
35
|
+
export declare function mergeRecords(local: CRDTRecord, remote: CRDTRecord): CRDTRecord;
|
|
36
|
+
/**
|
|
37
|
+
* Apply a local field write.
|
|
38
|
+
*
|
|
39
|
+
* Ticks the HLC for the given field (or creates a new entry if the field
|
|
40
|
+
* did not previously exist). The HLC of the new entry will be strictly
|
|
41
|
+
* greater than any existing HLC in the record.
|
|
42
|
+
*
|
|
43
|
+
* @param record - Current CRDTRecord (not mutated).
|
|
44
|
+
* @param field - Field name to write.
|
|
45
|
+
* @param value - New value.
|
|
46
|
+
* @param deviceId - The local device's identifier.
|
|
47
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
48
|
+
* @returns A new CRDTRecord with the field updated.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyLocalChange(record: CRDTRecord, field: string, value: FieldValue, deviceId: DeviceId, now: number): CRDTRecord;
|
|
51
|
+
/**
|
|
52
|
+
* Tombstone a field (soft-delete).
|
|
53
|
+
*
|
|
54
|
+
* The field's entry remains in the record with `deleted: true` and `value: null`,
|
|
55
|
+
* carrying a new HLC. This prevents a stale write from "resurrecting" the field.
|
|
56
|
+
*
|
|
57
|
+
* On merge, the tombstone wins over any older write via normal HLC comparison.
|
|
58
|
+
*
|
|
59
|
+
* @param record - Current CRDTRecord (not mutated).
|
|
60
|
+
* @param field - Field name to delete.
|
|
61
|
+
* @param deviceId - The local device's identifier.
|
|
62
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
63
|
+
* @returns A new CRDTRecord with the field tombstoned.
|
|
64
|
+
*/
|
|
65
|
+
export declare function deleteField(record: CRDTRecord, field: string, deviceId: DeviceId, now: number): CRDTRecord;
|
|
66
|
+
/**
|
|
67
|
+
* Create an empty CRDTRecord.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createRecord(): CRDTRecord;
|
|
70
|
+
/**
|
|
71
|
+
* Returns true if a field exists and has not been tombstoned.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isFieldAlive(record: CRDTRecord, field: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Get all non-deleted field names in the record.
|
|
76
|
+
*/
|
|
77
|
+
export declare function liveFields(record: CRDTRecord): string[];
|
|
78
|
+
//# sourceMappingURL=merge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../src/merge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAM/E;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAC9C,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,EACpB,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,GACpB,UAAU,CAAC,CAAC,CAAC,CAMf;AAMD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,GAAG,UAAU,CAiB9E;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,QAAQ,EAClB,GAAG,EAAE,MAAM,GACV,UAAU,CAYZ;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,QAAQ,EAClB,GAAG,EAAE,MAAM,GACV,UAAU,CAaZ;AAMD;;GAEG;AACH,wBAAgB,YAAY,IAAI,UAAU,CAEzC;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAGvE;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,EAAE,CAEvD"}
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT merge logic — the heart of the sync engine.
|
|
3
|
+
*
|
|
4
|
+
* All functions are PURE (no side effects, no async, no I/O).
|
|
5
|
+
* This makes them trivially testable and composable.
|
|
6
|
+
*
|
|
7
|
+
* Merge strategy: Last-Write-Wins Map (LWW-Map)
|
|
8
|
+
* - Each field independently stores its last-written value with an HLC timestamp.
|
|
9
|
+
* - On merge, the field with the higher HLC timestamp wins.
|
|
10
|
+
* - Tie-break: lexicographic comparison of DeviceId (deterministic, no coordination).
|
|
11
|
+
*
|
|
12
|
+
* Convergence properties (verified by property-based tests in merge.test.ts):
|
|
13
|
+
* - Commutativity: merge(A, B) ≡ merge(B, A)
|
|
14
|
+
* - Associativity: merge(merge(A,B),C) ≡ merge(A, merge(B,C))
|
|
15
|
+
* - Idempotence: merge(A, A) ≡ A
|
|
16
|
+
*/
|
|
17
|
+
import { compareHLC, tickHLC } from './hlc.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Field-level merge
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* Merge two FieldEntry values, returning the winner.
|
|
23
|
+
*
|
|
24
|
+
* Resolution order:
|
|
25
|
+
* 1. Higher HLC timestamp wins.
|
|
26
|
+
* 2. If HLC timestamps are equal, the lexicographically larger DeviceId wins.
|
|
27
|
+
* (Deterministic total order — all devices converge to the same result.)
|
|
28
|
+
*/
|
|
29
|
+
export function mergeFields(local, remote) {
|
|
30
|
+
const cmp = compareHLC(local.hlc, remote.hlc);
|
|
31
|
+
if (cmp > 0)
|
|
32
|
+
return local;
|
|
33
|
+
if (cmp < 0)
|
|
34
|
+
return remote;
|
|
35
|
+
// Tie on HLC — break by DeviceId (lexicographic, deterministic)
|
|
36
|
+
return local.origin >= remote.origin ? local : remote;
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Record-level merge
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Merge two CRDTRecords field by field.
|
|
43
|
+
*
|
|
44
|
+
* Fields that exist only in one record are included as-is (union semantics).
|
|
45
|
+
* Fields in both records are resolved with mergeFields.
|
|
46
|
+
*
|
|
47
|
+
* @returns A new CRDTRecord (does not mutate inputs).
|
|
48
|
+
*/
|
|
49
|
+
export function mergeRecords(local, remote) {
|
|
50
|
+
// Use a null-prototype object so prototype property names (e.g. "toString",
|
|
51
|
+
// "hasOwnProperty") are never shadowed by Object.prototype methods.
|
|
52
|
+
const result = Object.assign(Object.create(null), local);
|
|
53
|
+
for (const [field, remoteEntry] of Object.entries(remote)) {
|
|
54
|
+
const localEntry = Object.prototype.hasOwnProperty.call(result, field)
|
|
55
|
+
? result[field]
|
|
56
|
+
: undefined;
|
|
57
|
+
if (localEntry === undefined) {
|
|
58
|
+
result[field] = remoteEntry;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
result[field] = mergeFields(localEntry, remoteEntry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Local mutations
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Apply a local field write.
|
|
71
|
+
*
|
|
72
|
+
* Ticks the HLC for the given field (or creates a new entry if the field
|
|
73
|
+
* did not previously exist). The HLC of the new entry will be strictly
|
|
74
|
+
* greater than any existing HLC in the record.
|
|
75
|
+
*
|
|
76
|
+
* @param record - Current CRDTRecord (not mutated).
|
|
77
|
+
* @param field - Field name to write.
|
|
78
|
+
* @param value - New value.
|
|
79
|
+
* @param deviceId - The local device's identifier.
|
|
80
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
81
|
+
* @returns A new CRDTRecord with the field updated.
|
|
82
|
+
*/
|
|
83
|
+
export function applyLocalChange(record, field, value, deviceId, now) {
|
|
84
|
+
const existingHLC = record[field]?.hlc ?? { wallTime: 0, logical: 0 };
|
|
85
|
+
const newHLC = tickHLC(existingHLC, now);
|
|
86
|
+
return {
|
|
87
|
+
...record,
|
|
88
|
+
[field]: {
|
|
89
|
+
value,
|
|
90
|
+
hlc: newHLC,
|
|
91
|
+
origin: deviceId,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Tombstone a field (soft-delete).
|
|
97
|
+
*
|
|
98
|
+
* The field's entry remains in the record with `deleted: true` and `value: null`,
|
|
99
|
+
* carrying a new HLC. This prevents a stale write from "resurrecting" the field.
|
|
100
|
+
*
|
|
101
|
+
* On merge, the tombstone wins over any older write via normal HLC comparison.
|
|
102
|
+
*
|
|
103
|
+
* @param record - Current CRDTRecord (not mutated).
|
|
104
|
+
* @param field - Field name to delete.
|
|
105
|
+
* @param deviceId - The local device's identifier.
|
|
106
|
+
* @param now - Current wall-clock time (Date.now()).
|
|
107
|
+
* @returns A new CRDTRecord with the field tombstoned.
|
|
108
|
+
*/
|
|
109
|
+
export function deleteField(record, field, deviceId, now) {
|
|
110
|
+
const existingHLC = record[field]?.hlc ?? { wallTime: 0, logical: 0 };
|
|
111
|
+
const newHLC = tickHLC(existingHLC, now);
|
|
112
|
+
return {
|
|
113
|
+
...record,
|
|
114
|
+
[field]: {
|
|
115
|
+
value: null,
|
|
116
|
+
hlc: newHLC,
|
|
117
|
+
origin: deviceId,
|
|
118
|
+
deleted: true,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Utility
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Create an empty CRDTRecord.
|
|
127
|
+
*/
|
|
128
|
+
export function createRecord() {
|
|
129
|
+
return Object.create(null);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Returns true if a field exists and has not been tombstoned.
|
|
133
|
+
*/
|
|
134
|
+
export function isFieldAlive(record, field) {
|
|
135
|
+
const entry = record[field];
|
|
136
|
+
return entry !== undefined && entry.deleted !== true;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get all non-deleted field names in the record.
|
|
140
|
+
*/
|
|
141
|
+
export function liveFields(record) {
|
|
142
|
+
return Object.keys(record).filter((f) => isFieldAlive(record, f));
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=merge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge.js","sourceRoot":"","sources":["../src/merge.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core domain types for the CRDT sync engine.
|
|
3
|
+
*
|
|
4
|
+
* All types are plain data structures (no classes) to ensure serializability.
|
|
5
|
+
*/
|
|
6
|
+
/** Unique identifier for a device/replica. */
|
|
7
|
+
export type DeviceId = string;
|
|
8
|
+
/**
|
|
9
|
+
* A Hybrid Logical Clock timestamp.
|
|
10
|
+
* Combines wall-clock time (ms since epoch) with a logical counter
|
|
11
|
+
* to produce monotonic, totally-ordered timestamps across distributed devices.
|
|
12
|
+
*
|
|
13
|
+
* @see hlc.ts for the implementation.
|
|
14
|
+
*/
|
|
15
|
+
export interface HLC {
|
|
16
|
+
/** Wall-clock time in milliseconds (Date.now()). */
|
|
17
|
+
readonly wallTime: number;
|
|
18
|
+
/** Logical counter — incremented when wallTime ties. */
|
|
19
|
+
readonly logical: number;
|
|
20
|
+
}
|
|
21
|
+
/** Primitive field value types supported by the CRDT. */
|
|
22
|
+
export type FieldValue = string | number | boolean | null;
|
|
23
|
+
/**
|
|
24
|
+
* A single field entry in a CRDTRecord.
|
|
25
|
+
* Carries the value, the HLC timestamp of the last write, and the writer's DeviceId.
|
|
26
|
+
*
|
|
27
|
+
* When `deleted` is true, the field is tombstoned — a subsequent write with a
|
|
28
|
+
* lower HLC will NOT resurrect the deleted value.
|
|
29
|
+
*/
|
|
30
|
+
export interface FieldEntry<T extends FieldValue = FieldValue> {
|
|
31
|
+
/** The current field value, or null if the field is tombstoned. */
|
|
32
|
+
readonly value: T | null;
|
|
33
|
+
/** HLC timestamp of the last write to this field. */
|
|
34
|
+
readonly hlc: HLC;
|
|
35
|
+
/** The device that performed the last write. */
|
|
36
|
+
readonly origin: DeviceId;
|
|
37
|
+
/** Tombstone marker — present and true if this field was deleted. */
|
|
38
|
+
readonly deleted?: true;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A CRDT-managed record: a flat map of field names to FieldEntry values.
|
|
42
|
+
*
|
|
43
|
+
* Fields can hold any FieldValue. Field keys are arbitrary strings.
|
|
44
|
+
*/
|
|
45
|
+
export type CRDTRecord = Record<string, FieldEntry>;
|
|
46
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,8CAA8C;AAC9C,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE9B;;;;;;GAMG;AACH,MAAM,WAAW,GAAG;IAClB,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,yDAAyD;AACzD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;AAE1D;;;;;;GAMG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU;IAC3D,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IACzB,qDAAqD;IACrD,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,gDAAgD;IAChD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shet-anirudh/crdt-sync-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pure CRDT engine with LWW-Map and Hybrid Logical Clocks. Zero I/O, framework-agnostic.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"crdt",
|
|
7
|
+
"offline-first",
|
|
8
|
+
"sync",
|
|
9
|
+
"hlc",
|
|
10
|
+
"conflict-resolution",
|
|
11
|
+
"lww-map"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Anirudh Shet",
|
|
15
|
+
"homepage": "https://github.com/shet-anirudh/crdt-sync-engine#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/shet-anirudh/crdt-sync-engine.git",
|
|
19
|
+
"directory": "packages/core"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/shet-anirudh/crdt-sync-engine/issues"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@vitest/coverage-v8": "^2.0.5",
|
|
41
|
+
"fast-check": "^3.22.0",
|
|
42
|
+
"rimraf": "^6.0.1",
|
|
43
|
+
"vitest": "^2.0.5"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc --project tsconfig.json",
|
|
47
|
+
"build:watch": "tsc --project tsconfig.json --watch",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"test:coverage": "vitest run --coverage",
|
|
51
|
+
"lint": "eslint src --ext .ts",
|
|
52
|
+
"clean": "rimraf dist"
|
|
53
|
+
}
|
|
54
|
+
}
|