@syncular/core 0.0.1-60
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/dist/blobs.d.ts +137 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +47 -0
- package/dist/blobs.js.map +1 -0
- package/dist/conflict.d.ts +22 -0
- package/dist/conflict.d.ts.map +1 -0
- package/dist/conflict.js +81 -0
- package/dist/conflict.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/kysely-serialize.d.ts +22 -0
- package/dist/kysely-serialize.d.ts.map +1 -0
- package/dist/kysely-serialize.js +147 -0
- package/dist/kysely-serialize.js.map +1 -0
- package/dist/logger.d.ts +46 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +48 -0
- package/dist/logger.js.map +1 -0
- package/dist/proxy/index.d.ts +5 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +5 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/types.d.ts +54 -0
- package/dist/proxy/types.d.ts.map +1 -0
- package/dist/proxy/types.js +7 -0
- package/dist/proxy/types.js.map +1 -0
- package/dist/schemas/blobs.d.ts +76 -0
- package/dist/schemas/blobs.d.ts.map +1 -0
- package/dist/schemas/blobs.js +63 -0
- package/dist/schemas/blobs.js.map +1 -0
- package/dist/schemas/common.d.ts +28 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +26 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/index.d.ts +7 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +7 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/sync.d.ts +391 -0
- package/dist/schemas/sync.d.ts.map +1 -0
- package/dist/schemas/sync.js +156 -0
- package/dist/schemas/sync.js.map +1 -0
- package/dist/scopes/index.d.ts +65 -0
- package/dist/scopes/index.d.ts.map +1 -0
- package/dist/scopes/index.js +67 -0
- package/dist/scopes/index.js.map +1 -0
- package/dist/transforms.d.ts +146 -0
- package/dist/transforms.d.ts.map +1 -0
- package/dist/transforms.js +155 -0
- package/dist/transforms.js.map +1 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
- package/src/__tests__/conflict.test.ts +325 -0
- package/src/blobs.ts +187 -0
- package/src/conflict.ts +92 -0
- package/src/index.ts +30 -0
- package/src/kysely-serialize.ts +214 -0
- package/src/logger.ts +80 -0
- package/src/proxy/index.ts +10 -0
- package/src/proxy/types.ts +57 -0
- package/src/schemas/blobs.ts +101 -0
- package/src/schemas/common.ts +45 -0
- package/src/schemas/index.ts +7 -0
- package/src/schemas/sync.ts +222 -0
- package/src/scopes/index.ts +122 -0
- package/src/transforms.ts +256 -0
- package/src/types.ts +158 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for conflict detection utilities.
|
|
3
|
+
*
|
|
4
|
+
* Tests the field-level merge logic for sync push operations.
|
|
5
|
+
* These are pure function tests that don't require database setup.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { performFieldLevelMerge } from '../conflict';
|
|
9
|
+
|
|
10
|
+
describe('performFieldLevelMerge', () => {
|
|
11
|
+
describe('no base row (new insert)', () => {
|
|
12
|
+
test('client payload wins entirely when base row is null', () => {
|
|
13
|
+
const result = performFieldLevelMerge(
|
|
14
|
+
null, // no base row
|
|
15
|
+
{ id: 'team-1', name: 'Server Name', type: 'praxis' }, // server row
|
|
16
|
+
{ name: 'Client Name', type: 'op' } // client payload
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(result.canMerge).toBe(true);
|
|
20
|
+
if (result.canMerge) {
|
|
21
|
+
expect(result.mergedPayload).toEqual({
|
|
22
|
+
name: 'Client Name',
|
|
23
|
+
type: 'op',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('only client changed', () => {
|
|
30
|
+
test('uses client value when only client changed a field', () => {
|
|
31
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
32
|
+
const serverRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
33
|
+
const clientPayload = { name: 'Client Updated', type: 'praxis' };
|
|
34
|
+
|
|
35
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
36
|
+
|
|
37
|
+
expect(result.canMerge).toBe(true);
|
|
38
|
+
if (result.canMerge) {
|
|
39
|
+
expect(result.mergedPayload.name).toBe('Client Updated');
|
|
40
|
+
expect(result.mergedPayload.type).toBe('praxis');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('handles multiple fields changed by client only', () => {
|
|
45
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
46
|
+
const serverRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
47
|
+
const clientPayload = { name: 'New Name', type: 'op' };
|
|
48
|
+
|
|
49
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
50
|
+
|
|
51
|
+
expect(result.canMerge).toBe(true);
|
|
52
|
+
if (result.canMerge) {
|
|
53
|
+
expect(result.mergedPayload.name).toBe('New Name');
|
|
54
|
+
expect(result.mergedPayload.type).toBe('op');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('only server changed', () => {
|
|
60
|
+
test('keeps server value when only server changed a field', () => {
|
|
61
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
62
|
+
const serverRow = {
|
|
63
|
+
id: 'team-1',
|
|
64
|
+
name: 'Server Updated',
|
|
65
|
+
type: 'praxis',
|
|
66
|
+
};
|
|
67
|
+
const clientPayload = { name: 'Original', type: 'praxis' };
|
|
68
|
+
|
|
69
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
70
|
+
|
|
71
|
+
expect(result.canMerge).toBe(true);
|
|
72
|
+
if (result.canMerge) {
|
|
73
|
+
// Server's value is kept since client didn't change it
|
|
74
|
+
expect(result.mergedPayload.name).toBe('Server Updated');
|
|
75
|
+
expect(result.mergedPayload.type).toBe('praxis');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('both changed to same value', () => {
|
|
81
|
+
test('no conflict when both changed field to same value', () => {
|
|
82
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
83
|
+
const serverRow = { id: 'team-1', name: 'Same Value', type: 'praxis' };
|
|
84
|
+
const clientPayload = { name: 'Same Value', type: 'praxis' };
|
|
85
|
+
|
|
86
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
87
|
+
|
|
88
|
+
expect(result.canMerge).toBe(true);
|
|
89
|
+
if (result.canMerge) {
|
|
90
|
+
expect(result.mergedPayload.name).toBe('Same Value');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('both changed to different values (true conflict)', () => {
|
|
96
|
+
test('returns conflict when both changed same field to different values', () => {
|
|
97
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
98
|
+
const serverRow = { id: 'team-1', name: 'Server Value', type: 'praxis' };
|
|
99
|
+
const clientPayload = { name: 'Client Value', type: 'praxis' };
|
|
100
|
+
|
|
101
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
102
|
+
|
|
103
|
+
expect(result.canMerge).toBe(false);
|
|
104
|
+
if (!result.canMerge) {
|
|
105
|
+
expect(result.conflictingFields).toContain('name');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('reports multiple conflicting fields', () => {
|
|
110
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
111
|
+
const serverRow = {
|
|
112
|
+
id: 'team-1',
|
|
113
|
+
name: 'Server Name',
|
|
114
|
+
type: 'server-type',
|
|
115
|
+
};
|
|
116
|
+
const clientPayload = { name: 'Client Name', type: 'client-type' };
|
|
117
|
+
|
|
118
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
119
|
+
|
|
120
|
+
expect(result.canMerge).toBe(false);
|
|
121
|
+
if (!result.canMerge) {
|
|
122
|
+
expect(result.conflictingFields).toContain('name');
|
|
123
|
+
expect(result.conflictingFields).toContain('type');
|
|
124
|
+
expect(result.conflictingFields.length).toBe(2);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('mixed changes', () => {
|
|
130
|
+
test('handles client changed one field, server changed another', () => {
|
|
131
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
132
|
+
const serverRow = { id: 'team-1', name: 'Original', type: 'server-type' };
|
|
133
|
+
const clientPayload = { name: 'Client Name', type: 'praxis' };
|
|
134
|
+
|
|
135
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
136
|
+
|
|
137
|
+
expect(result.canMerge).toBe(true);
|
|
138
|
+
if (result.canMerge) {
|
|
139
|
+
// Client's name change is applied
|
|
140
|
+
expect(result.mergedPayload.name).toBe('Client Name');
|
|
141
|
+
// Server's type change is kept
|
|
142
|
+
expect(result.mergedPayload.type).toBe('server-type');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('handles conflict in one field but not another', () => {
|
|
147
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
148
|
+
const serverRow = { id: 'team-1', name: 'Server Name', type: 'praxis' };
|
|
149
|
+
const clientPayload = { name: 'Client Name', type: 'op' };
|
|
150
|
+
|
|
151
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
152
|
+
|
|
153
|
+
expect(result.canMerge).toBe(false);
|
|
154
|
+
if (!result.canMerge) {
|
|
155
|
+
// Only name conflicts (both changed from Original to different values)
|
|
156
|
+
expect(result.conflictingFields).toContain('name');
|
|
157
|
+
// Type was only changed by client, so no conflict
|
|
158
|
+
expect(result.conflictingFields).not.toContain('type');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('neither changed', () => {
|
|
164
|
+
test('returns server values when neither changed', () => {
|
|
165
|
+
const baseRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
166
|
+
const serverRow = { id: 'team-1', name: 'Original', type: 'praxis' };
|
|
167
|
+
const clientPayload = { name: 'Original', type: 'praxis' };
|
|
168
|
+
|
|
169
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
170
|
+
|
|
171
|
+
expect(result.canMerge).toBe(true);
|
|
172
|
+
if (result.canMerge) {
|
|
173
|
+
expect(result.mergedPayload.name).toBe('Original');
|
|
174
|
+
expect(result.mergedPayload.type).toBe('praxis');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('deepEqual (tested through performFieldLevelMerge)', () => {
|
|
181
|
+
describe('primitive equality', () => {
|
|
182
|
+
test('detects change in string values', () => {
|
|
183
|
+
const baseRow = { name: 'a' };
|
|
184
|
+
const serverRow = { name: 'b' };
|
|
185
|
+
const clientPayload = { name: 'c' };
|
|
186
|
+
|
|
187
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
188
|
+
|
|
189
|
+
expect(result.canMerge).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('detects change in number values', () => {
|
|
193
|
+
const baseRow = { count: 1 };
|
|
194
|
+
const serverRow = { count: 2 };
|
|
195
|
+
const clientPayload = { count: 3 };
|
|
196
|
+
|
|
197
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
198
|
+
|
|
199
|
+
expect(result.canMerge).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('handles null values', () => {
|
|
203
|
+
const baseRow = { value: null };
|
|
204
|
+
const serverRow = { value: null };
|
|
205
|
+
const clientPayload = { value: 'not null' };
|
|
206
|
+
|
|
207
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
208
|
+
|
|
209
|
+
expect(result.canMerge).toBe(true);
|
|
210
|
+
if (result.canMerge) {
|
|
211
|
+
expect(result.mergedPayload.value).toBe('not null');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('detects null to value change', () => {
|
|
216
|
+
const baseRow = { value: null };
|
|
217
|
+
const serverRow = { value: 'server' };
|
|
218
|
+
const clientPayload = { value: 'client' };
|
|
219
|
+
|
|
220
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
221
|
+
|
|
222
|
+
expect(result.canMerge).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('array equality', () => {
|
|
227
|
+
test('detects equal arrays', () => {
|
|
228
|
+
const baseRow = { tags: ['a', 'b'] };
|
|
229
|
+
const serverRow = { tags: ['a', 'b'] };
|
|
230
|
+
const clientPayload = { tags: ['a', 'b', 'c'] };
|
|
231
|
+
|
|
232
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
233
|
+
|
|
234
|
+
expect(result.canMerge).toBe(true);
|
|
235
|
+
if (result.canMerge) {
|
|
236
|
+
expect(result.mergedPayload.tags).toEqual(['a', 'b', 'c']);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('detects array length difference', () => {
|
|
241
|
+
const baseRow = { tags: ['a'] };
|
|
242
|
+
const serverRow = { tags: ['a', 'b'] };
|
|
243
|
+
const clientPayload = { tags: ['a', 'c'] };
|
|
244
|
+
|
|
245
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
246
|
+
|
|
247
|
+
expect(result.canMerge).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('detects array element difference', () => {
|
|
251
|
+
const baseRow = { tags: ['a', 'b'] };
|
|
252
|
+
const serverRow = { tags: ['a', 'x'] };
|
|
253
|
+
const clientPayload = { tags: ['a', 'y'] };
|
|
254
|
+
|
|
255
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
256
|
+
|
|
257
|
+
expect(result.canMerge).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('object equality', () => {
|
|
262
|
+
test('detects equal objects', () => {
|
|
263
|
+
const baseRow = { meta: { key: 'value' } };
|
|
264
|
+
const serverRow = { meta: { key: 'value' } };
|
|
265
|
+
const clientPayload = { meta: { key: 'new-value' } };
|
|
266
|
+
|
|
267
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
268
|
+
|
|
269
|
+
expect(result.canMerge).toBe(true);
|
|
270
|
+
if (result.canMerge) {
|
|
271
|
+
expect(result.mergedPayload.meta).toEqual({ key: 'new-value' });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('detects object key count difference', () => {
|
|
276
|
+
const baseRow = { meta: { a: 1 } };
|
|
277
|
+
const serverRow = { meta: { a: 1, b: 2 } };
|
|
278
|
+
const clientPayload = { meta: { a: 1, c: 3 } };
|
|
279
|
+
|
|
280
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
281
|
+
|
|
282
|
+
expect(result.canMerge).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('detects object value difference', () => {
|
|
286
|
+
const baseRow = { meta: { key: 'original' } };
|
|
287
|
+
const serverRow = { meta: { key: 'server' } };
|
|
288
|
+
const clientPayload = { meta: { key: 'client' } };
|
|
289
|
+
|
|
290
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
291
|
+
|
|
292
|
+
expect(result.canMerge).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('nested structures', () => {
|
|
297
|
+
test('handles deeply nested objects', () => {
|
|
298
|
+
const baseRow = { data: { nested: { deep: 'original' } } };
|
|
299
|
+
const serverRow = { data: { nested: { deep: 'original' } } };
|
|
300
|
+
const clientPayload = { data: { nested: { deep: 'updated' } } };
|
|
301
|
+
|
|
302
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
303
|
+
|
|
304
|
+
expect(result.canMerge).toBe(true);
|
|
305
|
+
if (result.canMerge) {
|
|
306
|
+
expect(result.mergedPayload.data).toEqual({
|
|
307
|
+
nested: { deep: 'updated' },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('handles arrays within objects', () => {
|
|
313
|
+
const baseRow = { config: { items: [1, 2, 3] } };
|
|
314
|
+
const serverRow = { config: { items: [1, 2, 3] } };
|
|
315
|
+
const clientPayload = { config: { items: [1, 2, 3, 4] } };
|
|
316
|
+
|
|
317
|
+
const result = performFieldLevelMerge(baseRow, serverRow, clientPayload);
|
|
318
|
+
|
|
319
|
+
expect(result.canMerge).toBe(true);
|
|
320
|
+
if (result.canMerge) {
|
|
321
|
+
expect(result.mergedPayload.config).toEqual({ items: [1, 2, 3, 4] });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
package/src/blobs.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Blob types for media/binary handling
|
|
3
|
+
*
|
|
4
|
+
* Content-addressable blob storage with presigned URL support.
|
|
5
|
+
* Protocol types (BlobRef, BlobMetadata, etc.) live in ./schemas/blobs.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BlobRef } from './schemas/blobs';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Client Transport Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Transport interface for client-server blob communication.
|
|
16
|
+
* This is used by the client blob manager to communicate with the server.
|
|
17
|
+
*/
|
|
18
|
+
export interface BlobTransport {
|
|
19
|
+
/**
|
|
20
|
+
* Initiate a blob upload.
|
|
21
|
+
* Returns presigned URL info or indicates blob already exists (dedup).
|
|
22
|
+
*/
|
|
23
|
+
initiateUpload(args: {
|
|
24
|
+
hash: string;
|
|
25
|
+
size: number;
|
|
26
|
+
mimeType: string;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
exists: boolean;
|
|
29
|
+
uploadUrl?: string;
|
|
30
|
+
uploadMethod?: 'PUT' | 'POST';
|
|
31
|
+
uploadHeaders?: Record<string, string>;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Complete a blob upload.
|
|
36
|
+
* Call this after uploading to the presigned URL.
|
|
37
|
+
*/
|
|
38
|
+
completeUpload(hash: string): Promise<{ ok: boolean; error?: string }>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get a presigned download URL.
|
|
42
|
+
*/
|
|
43
|
+
getDownloadUrl(hash: string): Promise<{
|
|
44
|
+
url: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Storage Adapter Types (Server-side)
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Options for signing an upload URL.
|
|
55
|
+
*/
|
|
56
|
+
export interface BlobSignUploadOptions {
|
|
57
|
+
/** SHA-256 hash (for naming and checksum validation) */
|
|
58
|
+
hash: string;
|
|
59
|
+
/** Content size in bytes */
|
|
60
|
+
size: number;
|
|
61
|
+
/** MIME type */
|
|
62
|
+
mimeType: string;
|
|
63
|
+
/** URL expiration in seconds */
|
|
64
|
+
expiresIn: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Result of signing an upload URL.
|
|
69
|
+
*/
|
|
70
|
+
export interface BlobSignedUpload {
|
|
71
|
+
/** The URL to upload to */
|
|
72
|
+
url: string;
|
|
73
|
+
/** HTTP method */
|
|
74
|
+
method: 'PUT' | 'POST';
|
|
75
|
+
/** Required headers */
|
|
76
|
+
headers?: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Options for signing a download URL.
|
|
81
|
+
*/
|
|
82
|
+
export interface BlobSignDownloadOptions {
|
|
83
|
+
/** SHA-256 hash */
|
|
84
|
+
hash: string;
|
|
85
|
+
/** URL expiration in seconds */
|
|
86
|
+
expiresIn: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adapter for blob storage backends (S3, R2, custom).
|
|
91
|
+
* Implementations handle actual storage; the sync server orchestrates.
|
|
92
|
+
*/
|
|
93
|
+
export interface BlobStorageAdapter {
|
|
94
|
+
/** Adapter name for logging/debugging */
|
|
95
|
+
readonly name: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate a presigned URL for uploading a blob.
|
|
99
|
+
* The URL should enforce checksum validation if the backend supports it.
|
|
100
|
+
*/
|
|
101
|
+
signUpload(options: BlobSignUploadOptions): Promise<BlobSignedUpload>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate a presigned URL for downloading a blob.
|
|
105
|
+
*/
|
|
106
|
+
signDownload(options: BlobSignDownloadOptions): Promise<string>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if a blob exists in storage.
|
|
110
|
+
*/
|
|
111
|
+
exists(hash: string): Promise<boolean>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete a blob (for garbage collection).
|
|
115
|
+
*/
|
|
116
|
+
delete(hash: string): Promise<void>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get blob metadata from storage (optional).
|
|
120
|
+
* Used to verify uploads completed successfully.
|
|
121
|
+
*/
|
|
122
|
+
getMetadata?(
|
|
123
|
+
hash: string
|
|
124
|
+
): Promise<{ size: number; mimeType?: string } | null>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Store blob data directly (for adapters that support direct storage).
|
|
128
|
+
* Used for snapshot chunks and other internal data.
|
|
129
|
+
*/
|
|
130
|
+
put?(
|
|
131
|
+
hash: string,
|
|
132
|
+
data: Uint8Array,
|
|
133
|
+
metadata?: Record<string, unknown>
|
|
134
|
+
): Promise<void>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get blob data directly (for adapters that support direct retrieval).
|
|
138
|
+
*/
|
|
139
|
+
get?(hash: string): Promise<Uint8Array | null>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Utility Functions
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a BlobRef from upload metadata.
|
|
148
|
+
*/
|
|
149
|
+
export function createBlobRef(args: {
|
|
150
|
+
hash: string;
|
|
151
|
+
size: number;
|
|
152
|
+
mimeType: string;
|
|
153
|
+
encrypted?: boolean;
|
|
154
|
+
keyId?: string;
|
|
155
|
+
}): BlobRef {
|
|
156
|
+
const ref: BlobRef = {
|
|
157
|
+
hash: args.hash,
|
|
158
|
+
size: args.size,
|
|
159
|
+
mimeType: args.mimeType,
|
|
160
|
+
};
|
|
161
|
+
if (args.encrypted) {
|
|
162
|
+
ref.encrypted = true;
|
|
163
|
+
if (args.keyId) {
|
|
164
|
+
ref.keyId = args.keyId;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return ref;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse a blob hash, validating format.
|
|
172
|
+
* @returns The hex hash without prefix, or null if invalid.
|
|
173
|
+
*/
|
|
174
|
+
export function parseBlobHash(hash: string): string | null {
|
|
175
|
+
if (!hash.startsWith('sha256:')) return null;
|
|
176
|
+
const hex = hash.slice(7);
|
|
177
|
+
if (hex.length !== 64) return null;
|
|
178
|
+
if (!/^[0-9a-f]+$/i.test(hex)) return null;
|
|
179
|
+
return hex.toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create a blob hash string from hex.
|
|
184
|
+
*/
|
|
185
|
+
export function createBlobHash(hexHash: string): string {
|
|
186
|
+
return `sha256:${hexHash.toLowerCase()}`;
|
|
187
|
+
}
|
package/src/conflict.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Pure conflict detection and field-level merge utilities
|
|
3
|
+
*
|
|
4
|
+
* These are pure functions with no database dependencies.
|
|
5
|
+
* Database-specific conflict detection (triggers, etc.) lives in @syncular/server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MergeResult } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Performs field-level merge between client changes and server state.
|
|
12
|
+
*
|
|
13
|
+
* Merge logic:
|
|
14
|
+
* - If only client changed a field -> use client's value
|
|
15
|
+
* - If only server changed a field -> keep server's value
|
|
16
|
+
* - If both changed same field to different values -> true conflict
|
|
17
|
+
*
|
|
18
|
+
* @param baseRow - The row state when client started editing (from base_version)
|
|
19
|
+
* @param serverRow - Current server row state
|
|
20
|
+
* @param clientPayload - Client's intended changes
|
|
21
|
+
* @returns MergeResult indicating if merge is possible and the result
|
|
22
|
+
*/
|
|
23
|
+
export function performFieldLevelMerge(
|
|
24
|
+
baseRow: Record<string, unknown> | null,
|
|
25
|
+
serverRow: Record<string, unknown>,
|
|
26
|
+
clientPayload: Record<string, unknown>
|
|
27
|
+
): MergeResult {
|
|
28
|
+
// If no base row (new insert), client payload wins entirely
|
|
29
|
+
if (!baseRow) {
|
|
30
|
+
return { canMerge: true, mergedPayload: clientPayload };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const conflictingFields: string[] = [];
|
|
34
|
+
const mergedPayload: Record<string, unknown> = { ...serverRow };
|
|
35
|
+
|
|
36
|
+
// Check each field in the client payload
|
|
37
|
+
for (const [field, clientValue] of Object.entries(clientPayload)) {
|
|
38
|
+
const baseValue = baseRow[field];
|
|
39
|
+
const serverValue = serverRow[field];
|
|
40
|
+
|
|
41
|
+
const clientChanged = !deepEqual(baseValue, clientValue);
|
|
42
|
+
const serverChanged = !deepEqual(baseValue, serverValue);
|
|
43
|
+
|
|
44
|
+
if (clientChanged && serverChanged) {
|
|
45
|
+
// Both changed the same field
|
|
46
|
+
if (!deepEqual(clientValue, serverValue)) {
|
|
47
|
+
// Changed to different values - true conflict
|
|
48
|
+
conflictingFields.push(field);
|
|
49
|
+
}
|
|
50
|
+
// If they changed to the same value, no conflict - use either
|
|
51
|
+
mergedPayload[field] = clientValue;
|
|
52
|
+
} else if (clientChanged) {
|
|
53
|
+
// Only client changed - use client's value
|
|
54
|
+
mergedPayload[field] = clientValue;
|
|
55
|
+
}
|
|
56
|
+
// If only server changed or neither changed, keep server value (already in mergedPayload)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (conflictingFields.length > 0) {
|
|
60
|
+
return { canMerge: false, conflictingFields };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { canMerge: true, mergedPayload };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Deep equality check for values (handles primitives, arrays, objects)
|
|
68
|
+
*/
|
|
69
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
70
|
+
if (a === b) return true;
|
|
71
|
+
if (a === null || b === null) return a === b;
|
|
72
|
+
if (typeof a !== typeof b) return false;
|
|
73
|
+
|
|
74
|
+
if (typeof a === 'object') {
|
|
75
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
76
|
+
if (a.length !== b.length) return false;
|
|
77
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(a) || Array.isArray(b)) return false;
|
|
81
|
+
|
|
82
|
+
const aObj = a as Record<string, unknown>;
|
|
83
|
+
const bObj = b as Record<string, unknown>;
|
|
84
|
+
const aKeys = Object.keys(aObj);
|
|
85
|
+
const bKeys = Object.keys(bObj);
|
|
86
|
+
|
|
87
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
88
|
+
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core - Shared types and utilities for sync infrastructure
|
|
3
|
+
*
|
|
4
|
+
* This package contains:
|
|
5
|
+
* - Protocol types (commit-log + subscriptions)
|
|
6
|
+
* - Pure conflict detection and merge utilities
|
|
7
|
+
* - Logging utilities
|
|
8
|
+
* - Data transformation hooks (optional)
|
|
9
|
+
* - Blob types for media/binary handling
|
|
10
|
+
* - Zod schemas for runtime validation and OpenAPI
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Blob transport/storage types and utilities (protocol types come from ./schemas)
|
|
14
|
+
export * from './blobs';
|
|
15
|
+
// Conflict detection utilities
|
|
16
|
+
export * from './conflict';
|
|
17
|
+
// Kysely plugin utilities
|
|
18
|
+
export * from './kysely-serialize';
|
|
19
|
+
// Logging utilities
|
|
20
|
+
export * from './logger';
|
|
21
|
+
// Proxy protocol types
|
|
22
|
+
export * from './proxy';
|
|
23
|
+
// Schemas (Zod)
|
|
24
|
+
export * from './schemas';
|
|
25
|
+
// Scope types, patterns, and utilities
|
|
26
|
+
export * from './scopes';
|
|
27
|
+
// Data transformation hooks
|
|
28
|
+
export * from './transforms';
|
|
29
|
+
// Transport and conflict types (protocol types come from ./schemas)
|
|
30
|
+
export * from './types';
|