@strata-sync/client 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/AGENTS.md +24 -0
- package/README.md +63 -0
- package/package.json +32 -0
- package/src/client.ts +1051 -0
- package/src/identity-map.ts +294 -0
- package/src/index.ts +33 -0
- package/src/outbox-manager.ts +399 -0
- package/src/query.ts +224 -0
- package/src/sync-orchestrator.ts +1041 -0
- package/src/types.ts +425 -0
- package/tests/rebase-integration.test.ts +920 -0
- package/tests/reverse-linear-sync-engine.test.ts +701 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type BatchLoadOptions,
|
|
4
|
+
type BootstrapMetadata,
|
|
5
|
+
type BootstrapOptions,
|
|
6
|
+
type ConnectionState,
|
|
7
|
+
type DeltaPacket,
|
|
8
|
+
type DeltaSubscription,
|
|
9
|
+
type ModelRegistrySnapshot,
|
|
10
|
+
type ModelRow,
|
|
11
|
+
type MutateResult,
|
|
12
|
+
noopReactivityAdapter,
|
|
13
|
+
type SchemaDefinition,
|
|
14
|
+
type SubscribeOptions,
|
|
15
|
+
type SyncAction,
|
|
16
|
+
type Transaction,
|
|
17
|
+
type TransactionBatch,
|
|
18
|
+
} from "../../sync-core/src/index";
|
|
19
|
+
import { createSyncClient } from "../src/index";
|
|
20
|
+
import type {
|
|
21
|
+
ModelPersistenceMeta,
|
|
22
|
+
StorageAdapter,
|
|
23
|
+
StorageMeta,
|
|
24
|
+
SyncClientEvent,
|
|
25
|
+
TransportAdapter,
|
|
26
|
+
} from "../src/types";
|
|
27
|
+
|
|
28
|
+
class InMemoryStorage implements StorageAdapter {
|
|
29
|
+
private readonly data = new Map<
|
|
30
|
+
string,
|
|
31
|
+
Map<string, Record<string, unknown>>
|
|
32
|
+
>();
|
|
33
|
+
private meta: StorageMeta = { lastSyncId: 0 };
|
|
34
|
+
private readonly modelPersistence = new Map<string, boolean>();
|
|
35
|
+
private readonly outbox: Transaction[] = [];
|
|
36
|
+
private readonly partialIndexes = new Set<string>();
|
|
37
|
+
private readonly syncActions: SyncAction[] = [];
|
|
38
|
+
|
|
39
|
+
open(_options: {
|
|
40
|
+
name?: string;
|
|
41
|
+
userId?: string;
|
|
42
|
+
version?: number;
|
|
43
|
+
userVersion?: number;
|
|
44
|
+
schema?: SchemaDefinition | ModelRegistrySnapshot;
|
|
45
|
+
}): Promise<void> {
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
close(): Promise<void> {
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
private getModelStore(
|
|
52
|
+
modelName: string
|
|
53
|
+
): Map<string, Record<string, unknown>> {
|
|
54
|
+
const existing = this.data.get(modelName);
|
|
55
|
+
if (existing) {
|
|
56
|
+
return existing;
|
|
57
|
+
}
|
|
58
|
+
const created = new Map<string, Record<string, unknown>>();
|
|
59
|
+
this.data.set(modelName, created);
|
|
60
|
+
return created;
|
|
61
|
+
}
|
|
62
|
+
get<T>(modelName: string, id: string): Promise<T | null> {
|
|
63
|
+
return Promise.resolve(
|
|
64
|
+
(this.data.get(modelName)?.get(id) as T | undefined) ?? null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
getAll<T>(modelName: string): Promise<T[]> {
|
|
68
|
+
const store = this.data.get(modelName);
|
|
69
|
+
return Promise.resolve(store ? (Array.from(store.values()) as T[]) : []);
|
|
70
|
+
}
|
|
71
|
+
put<T extends Record<string, unknown>>(
|
|
72
|
+
modelName: string,
|
|
73
|
+
row: T
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const id = row.id;
|
|
76
|
+
if (typeof id !== "string") {
|
|
77
|
+
throw new Error(`Missing id for ${modelName}`);
|
|
78
|
+
}
|
|
79
|
+
this.getModelStore(modelName).set(id, { ...row });
|
|
80
|
+
return Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
delete(modelName: string, id: string): Promise<void> {
|
|
83
|
+
this.data.get(modelName)?.delete(id);
|
|
84
|
+
return Promise.resolve();
|
|
85
|
+
}
|
|
86
|
+
getByIndex<T>(
|
|
87
|
+
modelName: string,
|
|
88
|
+
indexName: string,
|
|
89
|
+
key: string
|
|
90
|
+
): Promise<T[]> {
|
|
91
|
+
const store = this.data.get(modelName);
|
|
92
|
+
if (!store) {
|
|
93
|
+
return Promise.resolve([]);
|
|
94
|
+
}
|
|
95
|
+
const results: T[] = [];
|
|
96
|
+
for (const row of store.values()) {
|
|
97
|
+
if (row[indexName] === key) {
|
|
98
|
+
results.push(row as T);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return Promise.resolve(results);
|
|
102
|
+
}
|
|
103
|
+
async writeBatch(
|
|
104
|
+
ops: Array<{
|
|
105
|
+
type: "put" | "delete";
|
|
106
|
+
modelName: string;
|
|
107
|
+
id?: string;
|
|
108
|
+
data?: Record<string, unknown>;
|
|
109
|
+
}>
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
for (const op of ops) {
|
|
112
|
+
if (op.type === "put" && op.data) {
|
|
113
|
+
await this.put(op.modelName, op.data);
|
|
114
|
+
} else if (op.type === "delete" && op.id) {
|
|
115
|
+
await this.delete(op.modelName, op.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
getMeta(): Promise<StorageMeta> {
|
|
120
|
+
return Promise.resolve({ ...this.meta });
|
|
121
|
+
}
|
|
122
|
+
setMeta(meta: Partial<StorageMeta>): Promise<void> {
|
|
123
|
+
this.meta = { ...this.meta, ...meta };
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
}
|
|
126
|
+
getModelPersistence(modelName: string): Promise<ModelPersistenceMeta> {
|
|
127
|
+
return Promise.resolve({
|
|
128
|
+
modelName,
|
|
129
|
+
persisted: this.modelPersistence.get(modelName) ?? false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
setModelPersistence(modelName: string, persisted: boolean): Promise<void> {
|
|
133
|
+
this.modelPersistence.set(modelName, persisted);
|
|
134
|
+
return Promise.resolve();
|
|
135
|
+
}
|
|
136
|
+
getOutbox(): Promise<Transaction[]> {
|
|
137
|
+
return Promise.resolve([...this.outbox]);
|
|
138
|
+
}
|
|
139
|
+
addToOutbox(tx: Transaction): Promise<void> {
|
|
140
|
+
this.outbox.push(tx);
|
|
141
|
+
return Promise.resolve();
|
|
142
|
+
}
|
|
143
|
+
removeFromOutbox(clientTxId: string): Promise<void> {
|
|
144
|
+
const idx = this.outbox.findIndex((tx) => tx.clientTxId === clientTxId);
|
|
145
|
+
if (idx >= 0) {
|
|
146
|
+
this.outbox.splice(idx, 1);
|
|
147
|
+
}
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
}
|
|
150
|
+
updateOutboxTransaction(
|
|
151
|
+
clientTxId: string,
|
|
152
|
+
updates: Partial<Transaction>
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const tx = this.outbox.find((entry) => entry.clientTxId === clientTxId);
|
|
155
|
+
if (tx) {
|
|
156
|
+
Object.assign(tx, updates);
|
|
157
|
+
}
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
}
|
|
160
|
+
hasPartialIndex(
|
|
161
|
+
modelName: string,
|
|
162
|
+
indexedKey: string,
|
|
163
|
+
keyValue: string
|
|
164
|
+
): Promise<boolean> {
|
|
165
|
+
return Promise.resolve(
|
|
166
|
+
this.partialIndexes.has(`${modelName}:${indexedKey}:${keyValue}`)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
setPartialIndex(
|
|
170
|
+
modelName: string,
|
|
171
|
+
indexedKey: string,
|
|
172
|
+
keyValue: string
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
this.partialIndexes.add(`${modelName}:${indexedKey}:${keyValue}`);
|
|
175
|
+
return Promise.resolve();
|
|
176
|
+
}
|
|
177
|
+
addSyncActions(actions: SyncAction[]): Promise<void> {
|
|
178
|
+
this.syncActions.push(...actions);
|
|
179
|
+
return Promise.resolve();
|
|
180
|
+
}
|
|
181
|
+
getSyncActions(afterSyncId?: number, limit?: number): Promise<SyncAction[]> {
|
|
182
|
+
const filtered = afterSyncId
|
|
183
|
+
? this.syncActions.filter((a) => a.id > afterSyncId)
|
|
184
|
+
: [...this.syncActions];
|
|
185
|
+
return Promise.resolve(
|
|
186
|
+
typeof limit === "number" ? filtered.slice(0, limit) : filtered
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
clearSyncActions(): Promise<void> {
|
|
190
|
+
this.syncActions.length = 0;
|
|
191
|
+
return Promise.resolve();
|
|
192
|
+
}
|
|
193
|
+
clear(): Promise<void> {
|
|
194
|
+
this.data.clear();
|
|
195
|
+
this.modelPersistence.clear();
|
|
196
|
+
this.outbox.length = 0;
|
|
197
|
+
this.partialIndexes.clear();
|
|
198
|
+
this.syncActions.length = 0;
|
|
199
|
+
this.meta = { lastSyncId: 0 };
|
|
200
|
+
return Promise.resolve();
|
|
201
|
+
}
|
|
202
|
+
count(modelName: string): Promise<number> {
|
|
203
|
+
return Promise.resolve(this.data.get(modelName)?.size ?? 0);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
class AsyncQueue<T> implements AsyncIterable<T> {
|
|
208
|
+
private readonly items: T[] = [];
|
|
209
|
+
private readonly resolvers: Array<(result: IteratorResult<T>) => void> = [];
|
|
210
|
+
private closed = false;
|
|
211
|
+
|
|
212
|
+
push(item: T): void {
|
|
213
|
+
if (this.closed) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const resolver = this.resolvers.shift();
|
|
217
|
+
if (resolver) {
|
|
218
|
+
resolver({ value: item, done: false });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.items.push(item);
|
|
222
|
+
}
|
|
223
|
+
close(): void {
|
|
224
|
+
this.closed = true;
|
|
225
|
+
for (const r of this.resolvers.splice(0)) {
|
|
226
|
+
r({ value: undefined as T, done: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
230
|
+
return {
|
|
231
|
+
next: (): Promise<IteratorResult<T>> => {
|
|
232
|
+
const item = this.items.shift();
|
|
233
|
+
if (item !== undefined) {
|
|
234
|
+
return Promise.resolve({ value: item, done: false });
|
|
235
|
+
}
|
|
236
|
+
if (this.closed) {
|
|
237
|
+
return Promise.resolve({ value: undefined as T, done: true });
|
|
238
|
+
}
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
this.resolvers.push(resolve);
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
return: (): Promise<IteratorResult<T>> => {
|
|
244
|
+
this.close();
|
|
245
|
+
return Promise.resolve({ value: undefined as T, done: true });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class TestTransport implements TransportAdapter {
|
|
252
|
+
private readonly deltaQueue = new AsyncQueue<DeltaPacket>();
|
|
253
|
+
private readonly fullRows: ModelRow[];
|
|
254
|
+
private readonly fullMetadata: BootstrapMetadata;
|
|
255
|
+
private readonly connectionListeners = new Set<
|
|
256
|
+
(state: ConnectionState) => void
|
|
257
|
+
>();
|
|
258
|
+
private readonly connectionState: ConnectionState = "connected";
|
|
259
|
+
private nextSyncId: number;
|
|
260
|
+
|
|
261
|
+
constructor(options: {
|
|
262
|
+
fullRows: ModelRow[];
|
|
263
|
+
fullMetadata: BootstrapMetadata;
|
|
264
|
+
startingSyncId?: number;
|
|
265
|
+
}) {
|
|
266
|
+
this.fullRows = options.fullRows;
|
|
267
|
+
this.fullMetadata = options.fullMetadata;
|
|
268
|
+
this.nextSyncId = options.startingSyncId ?? 100;
|
|
269
|
+
}
|
|
270
|
+
bootstrap(
|
|
271
|
+
_options: BootstrapOptions
|
|
272
|
+
): AsyncGenerator<ModelRow, BootstrapMetadata, unknown> {
|
|
273
|
+
const rows = this.fullRows;
|
|
274
|
+
const metadata = this.fullMetadata;
|
|
275
|
+
// biome-ignore lint/suspicious/useAwait: async generator required for return type
|
|
276
|
+
return (async function* () {
|
|
277
|
+
for (const row of rows) {
|
|
278
|
+
yield row;
|
|
279
|
+
}
|
|
280
|
+
return metadata;
|
|
281
|
+
})();
|
|
282
|
+
}
|
|
283
|
+
batchLoad(
|
|
284
|
+
_options: BatchLoadOptions
|
|
285
|
+
): AsyncGenerator<ModelRow, void, unknown> {
|
|
286
|
+
return (async function* () {
|
|
287
|
+
// no batch data
|
|
288
|
+
})();
|
|
289
|
+
}
|
|
290
|
+
mutate(batch: TransactionBatch): Promise<MutateResult> {
|
|
291
|
+
const results = batch.transactions.map((tx) => {
|
|
292
|
+
this.nextSyncId += 1;
|
|
293
|
+
return {
|
|
294
|
+
clientTxId: tx.clientTxId,
|
|
295
|
+
success: true,
|
|
296
|
+
syncId: this.nextSyncId,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
return Promise.resolve({
|
|
300
|
+
success: true,
|
|
301
|
+
lastSyncId: this.nextSyncId,
|
|
302
|
+
results,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
subscribe(_options: SubscribeOptions): DeltaSubscription {
|
|
306
|
+
return {
|
|
307
|
+
[Symbol.asyncIterator]: () => this.deltaQueue[Symbol.asyncIterator](),
|
|
308
|
+
unsubscribe: () => this.deltaQueue.close(),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
emitDelta(packet: DeltaPacket): void {
|
|
312
|
+
this.deltaQueue.push(packet);
|
|
313
|
+
}
|
|
314
|
+
fetchDeltas(after: number): Promise<DeltaPacket> {
|
|
315
|
+
return Promise.resolve({ lastSyncId: after, actions: [] });
|
|
316
|
+
}
|
|
317
|
+
getConnectionState(): ConnectionState {
|
|
318
|
+
return this.connectionState;
|
|
319
|
+
}
|
|
320
|
+
onConnectionStateChange(
|
|
321
|
+
callback: (state: ConnectionState) => void
|
|
322
|
+
): () => void {
|
|
323
|
+
this.connectionListeners.add(callback);
|
|
324
|
+
callback(this.connectionState);
|
|
325
|
+
return () => {
|
|
326
|
+
this.connectionListeners.delete(callback);
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
close(): Promise<void> {
|
|
330
|
+
this.deltaQueue.close();
|
|
331
|
+
return Promise.resolve();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const schema: SchemaDefinition = {
|
|
336
|
+
models: {
|
|
337
|
+
Issue: {
|
|
338
|
+
loadStrategy: "instant",
|
|
339
|
+
groupKey: "teamId",
|
|
340
|
+
fields: { id: {}, title: {}, priority: {}, teamId: {} },
|
|
341
|
+
},
|
|
342
|
+
Team: {
|
|
343
|
+
loadStrategy: "instant",
|
|
344
|
+
fields: { id: {}, name: {} },
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const seedRows: ModelRow[] = [
|
|
350
|
+
{
|
|
351
|
+
modelName: "Issue",
|
|
352
|
+
data: { id: "issue-1", title: "Seed", priority: 1, teamId: "team-1" },
|
|
353
|
+
},
|
|
354
|
+
{ modelName: "Team", data: { id: "team-1", name: "Core" } },
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
const waitForSync = async (
|
|
358
|
+
client: ReturnType<typeof createSyncClient>,
|
|
359
|
+
expectedSyncId: number
|
|
360
|
+
): Promise<void> => {
|
|
361
|
+
await new Promise<void>((resolve, reject) => {
|
|
362
|
+
const timeout = setTimeout(
|
|
363
|
+
() => reject(new Error("Timed out waiting for sync")),
|
|
364
|
+
2000
|
|
365
|
+
);
|
|
366
|
+
const unsub = client.onEvent((event) => {
|
|
367
|
+
if (
|
|
368
|
+
event.type === "syncComplete" &&
|
|
369
|
+
event.lastSyncId === expectedSyncId
|
|
370
|
+
) {
|
|
371
|
+
clearTimeout(timeout);
|
|
372
|
+
unsub();
|
|
373
|
+
resolve();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const collectEvents = (
|
|
380
|
+
client: ReturnType<typeof createSyncClient>
|
|
381
|
+
): { events: SyncClientEvent[]; unsub: () => void } => {
|
|
382
|
+
const events: SyncClientEvent[] = [];
|
|
383
|
+
const unsub = client.onEvent((e) => events.push(e));
|
|
384
|
+
return { events, unsub };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
describe("rebase integration", () => {
|
|
388
|
+
it("update-update conflict with overlapping fields resolves server-wins", async () => {
|
|
389
|
+
const storage = new InMemoryStorage();
|
|
390
|
+
const transport = new TestTransport({
|
|
391
|
+
fullRows: seedRows,
|
|
392
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
393
|
+
startingSyncId: 50,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const client = createSyncClient({
|
|
397
|
+
storage,
|
|
398
|
+
transport,
|
|
399
|
+
reactivity: noopReactivityAdapter,
|
|
400
|
+
schema,
|
|
401
|
+
batchMutations: false,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
await client.start();
|
|
406
|
+
|
|
407
|
+
// Create a pending update for title
|
|
408
|
+
await client.update("Issue", "issue-1", { title: "Client Title" });
|
|
409
|
+
const outboxBefore = await storage.getOutbox();
|
|
410
|
+
expect(outboxBefore.length).toBeGreaterThanOrEqual(1);
|
|
411
|
+
|
|
412
|
+
const { events, unsub } = collectEvents(client);
|
|
413
|
+
|
|
414
|
+
// Server sends update for the same field (title) from another client
|
|
415
|
+
const delta: DeltaPacket = {
|
|
416
|
+
lastSyncId: 20,
|
|
417
|
+
actions: [
|
|
418
|
+
{
|
|
419
|
+
id: 20,
|
|
420
|
+
action: "U",
|
|
421
|
+
modelName: "Issue",
|
|
422
|
+
modelId: "issue-1",
|
|
423
|
+
data: { id: "issue-1", title: "Server Title", priority: 1 },
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const syncWaiter = waitForSync(client, 20);
|
|
429
|
+
transport.emitDelta(delta);
|
|
430
|
+
await syncWaiter;
|
|
431
|
+
|
|
432
|
+
// Conflict should have been detected and the local tx removed
|
|
433
|
+
const outboxAfter = await storage.getOutbox();
|
|
434
|
+
const remainingUpdateTxs = outboxAfter.filter(
|
|
435
|
+
(tx) => tx.action === "U" && tx.modelName === "Issue"
|
|
436
|
+
);
|
|
437
|
+
expect(remainingUpdateTxs).toHaveLength(0);
|
|
438
|
+
|
|
439
|
+
// A rebaseConflict event should have been emitted
|
|
440
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
441
|
+
expect(conflictEvents).toHaveLength(1);
|
|
442
|
+
expect(conflictEvents[0]).toMatchObject({
|
|
443
|
+
type: "rebaseConflict",
|
|
444
|
+
modelName: "Issue",
|
|
445
|
+
modelId: "issue-1",
|
|
446
|
+
conflictType: "update-update",
|
|
447
|
+
resolution: "server-wins",
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
unsub();
|
|
451
|
+
} finally {
|
|
452
|
+
await client.stop();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("update-update with non-overlapping fields does not conflict (field-level merge)", async () => {
|
|
457
|
+
const storage = new InMemoryStorage();
|
|
458
|
+
const transport = new TestTransport({
|
|
459
|
+
fullRows: seedRows,
|
|
460
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
461
|
+
startingSyncId: 50,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const client = createSyncClient({
|
|
465
|
+
storage,
|
|
466
|
+
transport,
|
|
467
|
+
reactivity: noopReactivityAdapter,
|
|
468
|
+
schema,
|
|
469
|
+
batchMutations: false,
|
|
470
|
+
fieldLevelConflicts: true,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
await client.start();
|
|
475
|
+
|
|
476
|
+
// Local update changes title
|
|
477
|
+
await client.update("Issue", "issue-1", { title: "My Title" });
|
|
478
|
+
|
|
479
|
+
const { events, unsub } = collectEvents(client);
|
|
480
|
+
|
|
481
|
+
// Server update changes priority (different field)
|
|
482
|
+
const delta: DeltaPacket = {
|
|
483
|
+
lastSyncId: 20,
|
|
484
|
+
actions: [
|
|
485
|
+
{
|
|
486
|
+
id: 20,
|
|
487
|
+
action: "U",
|
|
488
|
+
modelName: "Issue",
|
|
489
|
+
modelId: "issue-1",
|
|
490
|
+
data: { id: "issue-1", priority: 5 },
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const syncWaiter = waitForSync(client, 20);
|
|
496
|
+
transport.emitDelta(delta);
|
|
497
|
+
await syncWaiter;
|
|
498
|
+
|
|
499
|
+
// No conflict should be emitted
|
|
500
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
501
|
+
expect(conflictEvents).toHaveLength(0);
|
|
502
|
+
|
|
503
|
+
// Pending update should still be in the outbox
|
|
504
|
+
const outbox = await storage.getOutbox();
|
|
505
|
+
const titleUpdates = outbox.filter(
|
|
506
|
+
(tx) => tx.action === "U" && tx.modelName === "Issue"
|
|
507
|
+
);
|
|
508
|
+
expect(titleUpdates).toHaveLength(1);
|
|
509
|
+
|
|
510
|
+
// The pending tx should remain with its title payload intact
|
|
511
|
+
expect(titleUpdates[0]?.payload).toMatchObject({ title: "My Title" });
|
|
512
|
+
|
|
513
|
+
unsub();
|
|
514
|
+
} finally {
|
|
515
|
+
await client.stop();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("update-delete conflict resolves server-wins (delete wins)", async () => {
|
|
520
|
+
const storage = new InMemoryStorage();
|
|
521
|
+
const transport = new TestTransport({
|
|
522
|
+
fullRows: seedRows,
|
|
523
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
524
|
+
startingSyncId: 50,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const client = createSyncClient({
|
|
528
|
+
storage,
|
|
529
|
+
transport,
|
|
530
|
+
reactivity: noopReactivityAdapter,
|
|
531
|
+
schema,
|
|
532
|
+
batchMutations: false,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
await client.start();
|
|
537
|
+
|
|
538
|
+
// Local update
|
|
539
|
+
await client.update("Issue", "issue-1", { title: "Updated" });
|
|
540
|
+
|
|
541
|
+
const { events, unsub } = collectEvents(client);
|
|
542
|
+
|
|
543
|
+
// Server deletes the same entity
|
|
544
|
+
const delta: DeltaPacket = {
|
|
545
|
+
lastSyncId: 20,
|
|
546
|
+
actions: [
|
|
547
|
+
{
|
|
548
|
+
id: 20,
|
|
549
|
+
action: "D",
|
|
550
|
+
modelName: "Issue",
|
|
551
|
+
modelId: "issue-1",
|
|
552
|
+
data: {},
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const syncWaiter = waitForSync(client, 20);
|
|
558
|
+
transport.emitDelta(delta);
|
|
559
|
+
await syncWaiter;
|
|
560
|
+
|
|
561
|
+
// The update tx should be removed from outbox
|
|
562
|
+
const outbox = await storage.getOutbox();
|
|
563
|
+
const issueTxs = outbox.filter((tx) => tx.modelName === "Issue");
|
|
564
|
+
expect(issueTxs).toHaveLength(0);
|
|
565
|
+
|
|
566
|
+
// Conflict event emitted
|
|
567
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
568
|
+
expect(conflictEvents).toHaveLength(1);
|
|
569
|
+
expect(conflictEvents[0]).toMatchObject({
|
|
570
|
+
conflictType: "update-delete",
|
|
571
|
+
resolution: "server-wins",
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
unsub();
|
|
575
|
+
} finally {
|
|
576
|
+
await client.stop();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("delete-update conflict resolves server-wins (update wins)", async () => {
|
|
581
|
+
const storage = new InMemoryStorage();
|
|
582
|
+
const transport = new TestTransport({
|
|
583
|
+
fullRows: seedRows,
|
|
584
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
585
|
+
startingSyncId: 50,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const client = createSyncClient({
|
|
589
|
+
storage,
|
|
590
|
+
transport,
|
|
591
|
+
reactivity: noopReactivityAdapter,
|
|
592
|
+
schema,
|
|
593
|
+
batchMutations: false,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
await client.start();
|
|
598
|
+
|
|
599
|
+
// Local delete
|
|
600
|
+
await client.delete("Issue", "issue-1", {
|
|
601
|
+
original: {
|
|
602
|
+
id: "issue-1",
|
|
603
|
+
title: "Seed",
|
|
604
|
+
priority: 1,
|
|
605
|
+
teamId: "team-1",
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const { events, unsub } = collectEvents(client);
|
|
610
|
+
|
|
611
|
+
// Server updates the same entity
|
|
612
|
+
const delta: DeltaPacket = {
|
|
613
|
+
lastSyncId: 20,
|
|
614
|
+
actions: [
|
|
615
|
+
{
|
|
616
|
+
id: 20,
|
|
617
|
+
action: "U",
|
|
618
|
+
modelName: "Issue",
|
|
619
|
+
modelId: "issue-1",
|
|
620
|
+
data: {
|
|
621
|
+
id: "issue-1",
|
|
622
|
+
title: "Server Update",
|
|
623
|
+
priority: 1,
|
|
624
|
+
teamId: "team-1",
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const syncWaiter = waitForSync(client, 20);
|
|
631
|
+
transport.emitDelta(delta);
|
|
632
|
+
await syncWaiter;
|
|
633
|
+
|
|
634
|
+
// The delete tx should be removed (server-wins)
|
|
635
|
+
const outbox = await storage.getOutbox();
|
|
636
|
+
const deleteTxs = outbox.filter(
|
|
637
|
+
(tx) => tx.action === "D" && tx.modelName === "Issue"
|
|
638
|
+
);
|
|
639
|
+
expect(deleteTxs).toHaveLength(0);
|
|
640
|
+
|
|
641
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
642
|
+
expect(conflictEvents).toHaveLength(1);
|
|
643
|
+
expect(conflictEvents[0]).toMatchObject({
|
|
644
|
+
conflictType: "delete-update",
|
|
645
|
+
resolution: "server-wins",
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
unsub();
|
|
649
|
+
} finally {
|
|
650
|
+
await client.stop();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("insert-insert conflict resolves server-wins", async () => {
|
|
655
|
+
const storage = new InMemoryStorage();
|
|
656
|
+
const transport = new TestTransport({
|
|
657
|
+
fullRows: seedRows,
|
|
658
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
659
|
+
startingSyncId: 50,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const client = createSyncClient({
|
|
663
|
+
storage,
|
|
664
|
+
transport,
|
|
665
|
+
reactivity: noopReactivityAdapter,
|
|
666
|
+
schema,
|
|
667
|
+
batchMutations: false,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
await client.start();
|
|
672
|
+
|
|
673
|
+
// Local insert with specific ID
|
|
674
|
+
await client.create("Issue", {
|
|
675
|
+
id: "issue-dup",
|
|
676
|
+
title: "Client Version",
|
|
677
|
+
teamId: "team-1",
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const { events, unsub } = collectEvents(client);
|
|
681
|
+
|
|
682
|
+
// Server inserts same ID
|
|
683
|
+
const delta: DeltaPacket = {
|
|
684
|
+
lastSyncId: 20,
|
|
685
|
+
actions: [
|
|
686
|
+
{
|
|
687
|
+
id: 20,
|
|
688
|
+
action: "I",
|
|
689
|
+
modelName: "Issue",
|
|
690
|
+
modelId: "issue-dup",
|
|
691
|
+
data: {
|
|
692
|
+
id: "issue-dup",
|
|
693
|
+
title: "Server Version",
|
|
694
|
+
teamId: "team-1",
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const syncWaiter = waitForSync(client, 20);
|
|
701
|
+
transport.emitDelta(delta);
|
|
702
|
+
await syncWaiter;
|
|
703
|
+
|
|
704
|
+
// Insert tx should be removed
|
|
705
|
+
const outbox = await storage.getOutbox();
|
|
706
|
+
const insertTxs = outbox.filter(
|
|
707
|
+
(tx) => tx.action === "I" && tx.modelId === "issue-dup"
|
|
708
|
+
);
|
|
709
|
+
expect(insertTxs).toHaveLength(0);
|
|
710
|
+
|
|
711
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
712
|
+
expect(conflictEvents).toHaveLength(1);
|
|
713
|
+
expect(conflictEvents[0]).toMatchObject({
|
|
714
|
+
conflictType: "insert-insert",
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
unsub();
|
|
718
|
+
} finally {
|
|
719
|
+
await client.stop();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("no conflict when delta targets different entities", async () => {
|
|
724
|
+
const storage = new InMemoryStorage();
|
|
725
|
+
const transport = new TestTransport({
|
|
726
|
+
fullRows: seedRows,
|
|
727
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
728
|
+
startingSyncId: 50,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const client = createSyncClient({
|
|
732
|
+
storage,
|
|
733
|
+
transport,
|
|
734
|
+
reactivity: noopReactivityAdapter,
|
|
735
|
+
schema,
|
|
736
|
+
batchMutations: false,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
await client.start();
|
|
741
|
+
|
|
742
|
+
// Local update on issue-1
|
|
743
|
+
await client.update("Issue", "issue-1", { title: "Local" });
|
|
744
|
+
const outboxBefore = await storage.getOutbox();
|
|
745
|
+
const pendingCount = outboxBefore.filter(
|
|
746
|
+
(tx) => tx.action === "U" && tx.modelName === "Issue"
|
|
747
|
+
).length;
|
|
748
|
+
|
|
749
|
+
const { events, unsub } = collectEvents(client);
|
|
750
|
+
|
|
751
|
+
// Server updates a totally different entity
|
|
752
|
+
const delta: DeltaPacket = {
|
|
753
|
+
lastSyncId: 20,
|
|
754
|
+
actions: [
|
|
755
|
+
{
|
|
756
|
+
id: 20,
|
|
757
|
+
action: "U",
|
|
758
|
+
modelName: "Team",
|
|
759
|
+
modelId: "team-1",
|
|
760
|
+
data: { id: "team-1", name: "Renamed" },
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const syncWaiter = waitForSync(client, 20);
|
|
766
|
+
transport.emitDelta(delta);
|
|
767
|
+
await syncWaiter;
|
|
768
|
+
|
|
769
|
+
// No conflict
|
|
770
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
771
|
+
expect(conflictEvents).toHaveLength(0);
|
|
772
|
+
|
|
773
|
+
// Pending update still in outbox
|
|
774
|
+
const outboxAfter = await storage.getOutbox();
|
|
775
|
+
const remaining = outboxAfter.filter(
|
|
776
|
+
(tx) => tx.action === "U" && tx.modelName === "Issue"
|
|
777
|
+
);
|
|
778
|
+
expect(remaining).toHaveLength(pendingCount);
|
|
779
|
+
|
|
780
|
+
unsub();
|
|
781
|
+
} finally {
|
|
782
|
+
await client.stop();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("archive-delete conflict is detected via normalization", async () => {
|
|
787
|
+
const storage = new InMemoryStorage();
|
|
788
|
+
const transport = new TestTransport({
|
|
789
|
+
fullRows: seedRows,
|
|
790
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
791
|
+
startingSyncId: 50,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const client = createSyncClient({
|
|
795
|
+
storage,
|
|
796
|
+
transport,
|
|
797
|
+
reactivity: noopReactivityAdapter,
|
|
798
|
+
schema,
|
|
799
|
+
batchMutations: false,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
await client.start();
|
|
804
|
+
|
|
805
|
+
// Local archive
|
|
806
|
+
await client.archive("Issue", "issue-1", {
|
|
807
|
+
original: {
|
|
808
|
+
id: "issue-1",
|
|
809
|
+
title: "Seed",
|
|
810
|
+
priority: 1,
|
|
811
|
+
teamId: "team-1",
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const { events, unsub } = collectEvents(client);
|
|
816
|
+
|
|
817
|
+
// Server deletes the same entity
|
|
818
|
+
const delta: DeltaPacket = {
|
|
819
|
+
lastSyncId: 20,
|
|
820
|
+
actions: [
|
|
821
|
+
{
|
|
822
|
+
id: 20,
|
|
823
|
+
action: "D",
|
|
824
|
+
modelName: "Issue",
|
|
825
|
+
modelId: "issue-1",
|
|
826
|
+
data: {},
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const syncWaiter = waitForSync(client, 20);
|
|
832
|
+
transport.emitDelta(delta);
|
|
833
|
+
await syncWaiter;
|
|
834
|
+
|
|
835
|
+
// Archive tx removed (server-wins)
|
|
836
|
+
const outbox = await storage.getOutbox();
|
|
837
|
+
const archiveTxs = outbox.filter(
|
|
838
|
+
(tx) => tx.action === "A" && tx.modelName === "Issue"
|
|
839
|
+
);
|
|
840
|
+
expect(archiveTxs).toHaveLength(0);
|
|
841
|
+
|
|
842
|
+
// Conflict detected as update-delete (archive normalized to update)
|
|
843
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
844
|
+
expect(conflictEvents).toHaveLength(1);
|
|
845
|
+
expect(conflictEvents[0]).toMatchObject({
|
|
846
|
+
conflictType: "update-delete",
|
|
847
|
+
resolution: "server-wins",
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
unsub();
|
|
851
|
+
} finally {
|
|
852
|
+
await client.stop();
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("confirmed own transaction is not treated as conflict", async () => {
|
|
857
|
+
const storage = new InMemoryStorage();
|
|
858
|
+
const transport = new TestTransport({
|
|
859
|
+
fullRows: seedRows,
|
|
860
|
+
fullMetadata: { lastSyncId: 10, subscribedSyncGroups: ["team-1"] },
|
|
861
|
+
startingSyncId: 50,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
const client = createSyncClient({
|
|
865
|
+
storage,
|
|
866
|
+
transport,
|
|
867
|
+
reactivity: noopReactivityAdapter,
|
|
868
|
+
schema,
|
|
869
|
+
batchMutations: false,
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
await client.start();
|
|
874
|
+
|
|
875
|
+
let createdTx: Transaction | undefined;
|
|
876
|
+
await client.update(
|
|
877
|
+
"Issue",
|
|
878
|
+
"issue-1",
|
|
879
|
+
{ title: "My Update" },
|
|
880
|
+
{
|
|
881
|
+
onTransactionCreated: (tx) => {
|
|
882
|
+
createdTx = tx;
|
|
883
|
+
},
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
expect(createdTx).toBeDefined();
|
|
888
|
+
|
|
889
|
+
const { events, unsub } = collectEvents(client);
|
|
890
|
+
|
|
891
|
+
// Server echoes back our own transaction
|
|
892
|
+
const delta: DeltaPacket = {
|
|
893
|
+
lastSyncId: 20,
|
|
894
|
+
actions: [
|
|
895
|
+
{
|
|
896
|
+
id: 20,
|
|
897
|
+
action: "U",
|
|
898
|
+
modelName: "Issue",
|
|
899
|
+
modelId: "issue-1",
|
|
900
|
+
data: { id: "issue-1", title: "My Update" },
|
|
901
|
+
clientTxId: createdTx?.clientTxId,
|
|
902
|
+
clientId: client.clientId,
|
|
903
|
+
},
|
|
904
|
+
],
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const syncWaiter = waitForSync(client, 20);
|
|
908
|
+
transport.emitDelta(delta);
|
|
909
|
+
await syncWaiter;
|
|
910
|
+
|
|
911
|
+
// No conflict events — this is a confirmation, not a conflict
|
|
912
|
+
const conflictEvents = events.filter((e) => e.type === "rebaseConflict");
|
|
913
|
+
expect(conflictEvents).toHaveLength(0);
|
|
914
|
+
|
|
915
|
+
unsub();
|
|
916
|
+
} finally {
|
|
917
|
+
await client.stop();
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
});
|