@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,294 @@
|
|
|
1
|
+
import type { ObservableMap, ReactivityAdapter } from "@strata-sync/core";
|
|
2
|
+
import type { ModelFactory } from "./types";
|
|
3
|
+
|
|
4
|
+
interface ModelInstanceLike {
|
|
5
|
+
_applyUpdate?: (data: Record<string, unknown>) => void;
|
|
6
|
+
makeObservable?: () => void;
|
|
7
|
+
toJSON?: () => Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const isModelInstanceLike = (value: unknown): value is ModelInstanceLike => {
|
|
11
|
+
if (!value || typeof value !== "object") {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const record = value as Record<string, unknown>;
|
|
15
|
+
return (
|
|
16
|
+
typeof record._applyUpdate === "function" ||
|
|
17
|
+
typeof record.makeObservable === "function"
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Identity map for managing model instances
|
|
23
|
+
* Ensures only one instance exists per model+id combination
|
|
24
|
+
*/
|
|
25
|
+
export class IdentityMap<T extends Record<string, unknown>> {
|
|
26
|
+
private readonly map: ObservableMap<string, T>;
|
|
27
|
+
private readonly modelName: string;
|
|
28
|
+
private readonly reactivity: ReactivityAdapter;
|
|
29
|
+
private modelFactory?: ModelFactory;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
modelName: string,
|
|
33
|
+
reactivity: ReactivityAdapter,
|
|
34
|
+
modelFactory?: ModelFactory
|
|
35
|
+
) {
|
|
36
|
+
this.modelName = modelName;
|
|
37
|
+
this.reactivity = reactivity;
|
|
38
|
+
this.modelFactory = modelFactory;
|
|
39
|
+
this.map = reactivity.createMap<string, T>(undefined, {
|
|
40
|
+
name: `IdentityMap:${modelName}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setModelFactory(modelFactory?: ModelFactory): void {
|
|
45
|
+
this.modelFactory = modelFactory;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private ensureObservable(instance: T): T {
|
|
49
|
+
if (
|
|
50
|
+
isModelInstanceLike(instance) &&
|
|
51
|
+
typeof instance.makeObservable === "function"
|
|
52
|
+
) {
|
|
53
|
+
instance.makeObservable();
|
|
54
|
+
}
|
|
55
|
+
return instance;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private toInstance(data: T): T {
|
|
59
|
+
if (!this.modelFactory || isModelInstanceLike(data)) {
|
|
60
|
+
return this.ensureObservable(data);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const instance = this.modelFactory(
|
|
64
|
+
this.modelName,
|
|
65
|
+
data as Record<string, unknown>
|
|
66
|
+
) as T;
|
|
67
|
+
return this.ensureObservable(instance);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Gets a model instance by ID
|
|
72
|
+
*/
|
|
73
|
+
get(id: string): T | undefined {
|
|
74
|
+
return this.map.get(id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Checks if a model instance exists
|
|
79
|
+
*/
|
|
80
|
+
has(id: string): boolean {
|
|
81
|
+
return this.map.has(id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sets a model instance, replacing any existing instance
|
|
86
|
+
*/
|
|
87
|
+
set(id: string, instance: T): void {
|
|
88
|
+
this.reactivity.runInAction(() => {
|
|
89
|
+
this.map.set(id, this.toInstance(instance));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Updates an existing model instance in place
|
|
95
|
+
*/
|
|
96
|
+
update(id: string, changes: Partial<T>): T | undefined {
|
|
97
|
+
const existing = this.map.get(id);
|
|
98
|
+
if (!existing) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.reactivity.runInAction(() => {
|
|
103
|
+
this.applyChanges(existing, changes);
|
|
104
|
+
this.map.set(id, this.ensureObservable(existing));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return this.map.get(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Merges data into an existing instance or creates a new one
|
|
112
|
+
*/
|
|
113
|
+
merge(id: string, data: Partial<T>): T {
|
|
114
|
+
return this.reactivity.runInAction(() => {
|
|
115
|
+
const existing = this.map.get(id);
|
|
116
|
+
if (existing) {
|
|
117
|
+
this.applyChanges(existing, data);
|
|
118
|
+
this.map.set(id, this.ensureObservable(existing));
|
|
119
|
+
return existing;
|
|
120
|
+
}
|
|
121
|
+
const merged = this.toInstance(data as T);
|
|
122
|
+
this.map.set(id, merged);
|
|
123
|
+
return merged;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Deletes a model instance
|
|
129
|
+
*/
|
|
130
|
+
delete(id: string): boolean {
|
|
131
|
+
return this.reactivity.runInAction(() => {
|
|
132
|
+
return this.map.delete(id);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Clears all model instances
|
|
138
|
+
*/
|
|
139
|
+
clear(): void {
|
|
140
|
+
this.reactivity.runInAction(() => {
|
|
141
|
+
this.map.clear();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Gets all model instances
|
|
147
|
+
*/
|
|
148
|
+
values(): T[] {
|
|
149
|
+
return Array.from(this.map.values());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Gets all model IDs
|
|
154
|
+
*/
|
|
155
|
+
keys(): string[] {
|
|
156
|
+
return Array.from(this.map.keys());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Gets all entries
|
|
161
|
+
*/
|
|
162
|
+
entries(): [string, T][] {
|
|
163
|
+
return Array.from(this.map.entries());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Gets the number of instances
|
|
168
|
+
*/
|
|
169
|
+
get size(): number {
|
|
170
|
+
return this.map.size;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Iterates over all instances
|
|
175
|
+
*/
|
|
176
|
+
forEach(callback: (value: T, key: string) => void): void {
|
|
177
|
+
this.map.forEach(callback);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Finds an instance matching a predicate
|
|
182
|
+
*/
|
|
183
|
+
find(predicate: (value: T) => boolean): T | undefined {
|
|
184
|
+
for (const value of this.map.values()) {
|
|
185
|
+
if (predicate(value)) {
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Filters instances matching a predicate
|
|
194
|
+
*/
|
|
195
|
+
filter(predicate: (value: T) => boolean): T[] {
|
|
196
|
+
return this.values().filter(predicate);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gets the model name
|
|
201
|
+
*/
|
|
202
|
+
getModelName(): string {
|
|
203
|
+
return this.modelName;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Gets the underlying map (for advanced usage)
|
|
208
|
+
*/
|
|
209
|
+
getRawMap(): Map<string, T> {
|
|
210
|
+
return new Map(this.map.entries());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Applies changes to an existing instance, preserving identity
|
|
215
|
+
*/
|
|
216
|
+
private applyChanges(target: T, changes: Partial<T>): void {
|
|
217
|
+
const candidate = target as ModelInstanceLike;
|
|
218
|
+
|
|
219
|
+
if (typeof candidate._applyUpdate === "function") {
|
|
220
|
+
candidate._applyUpdate(changes as Record<string, unknown>);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
Object.assign(target, changes);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Manages identity maps for all model types
|
|
230
|
+
*/
|
|
231
|
+
export class IdentityMapRegistry {
|
|
232
|
+
private readonly maps: Map<string, IdentityMap<Record<string, unknown>>> =
|
|
233
|
+
new Map();
|
|
234
|
+
private readonly reactivity: ReactivityAdapter;
|
|
235
|
+
private modelFactory?: ModelFactory;
|
|
236
|
+
|
|
237
|
+
constructor(reactivity: ReactivityAdapter, modelFactory?: ModelFactory) {
|
|
238
|
+
this.reactivity = reactivity;
|
|
239
|
+
this.modelFactory = modelFactory;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
setModelFactory(modelFactory?: ModelFactory): void {
|
|
243
|
+
this.modelFactory = modelFactory;
|
|
244
|
+
for (const map of this.maps.values()) {
|
|
245
|
+
map.setModelFactory(modelFactory);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Gets or creates an identity map for a model type
|
|
251
|
+
*/
|
|
252
|
+
getMap<T extends Record<string, unknown>>(modelName: string): IdentityMap<T> {
|
|
253
|
+
let map = this.maps.get(modelName);
|
|
254
|
+
if (!map) {
|
|
255
|
+
map = new IdentityMap<Record<string, unknown>>(
|
|
256
|
+
modelName,
|
|
257
|
+
this.reactivity,
|
|
258
|
+
this.modelFactory
|
|
259
|
+
);
|
|
260
|
+
this.maps.set(modelName, map);
|
|
261
|
+
}
|
|
262
|
+
return map as IdentityMap<T>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Checks if a map exists for a model type
|
|
267
|
+
*/
|
|
268
|
+
hasMap(modelName: string): boolean {
|
|
269
|
+
return this.maps.has(modelName);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Clears all identity maps
|
|
274
|
+
*/
|
|
275
|
+
clearAll(): void {
|
|
276
|
+
for (const map of this.maps.values()) {
|
|
277
|
+
map.clear();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Clears a specific identity map
|
|
283
|
+
*/
|
|
284
|
+
clear(modelName: string): void {
|
|
285
|
+
this.maps.get(modelName)?.clear();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Gets all model names with identity maps
|
|
290
|
+
*/
|
|
291
|
+
getModelNames(): string[] {
|
|
292
|
+
return Array.from(this.maps.keys());
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// biome-ignore-all lint/performance/noBarrelFile: public API
|
|
2
|
+
export { createSyncClient } from "./client";
|
|
3
|
+
export { IdentityMap, IdentityMapRegistry } from "./identity-map";
|
|
4
|
+
export { OutboxManager } from "./outbox-manager";
|
|
5
|
+
export {
|
|
6
|
+
and,
|
|
7
|
+
combineSorts,
|
|
8
|
+
contains,
|
|
9
|
+
eq,
|
|
10
|
+
executeQuery,
|
|
11
|
+
gt,
|
|
12
|
+
isIn,
|
|
13
|
+
lt,
|
|
14
|
+
matches,
|
|
15
|
+
neq,
|
|
16
|
+
not,
|
|
17
|
+
or,
|
|
18
|
+
sortBy,
|
|
19
|
+
} from "./query";
|
|
20
|
+
export { SyncOrchestrator } from "./sync-orchestrator";
|
|
21
|
+
|
|
22
|
+
export type OutboxManagerOptions =
|
|
23
|
+
import("./outbox-manager").OutboxManagerOptions;
|
|
24
|
+
export type QueryOptions<T> = import("./types").QueryOptions<T>;
|
|
25
|
+
export type QueryResult<T> = import("./types").QueryResult<T>;
|
|
26
|
+
export type StorageAdapter = import("./types").StorageAdapter;
|
|
27
|
+
export type StorageMeta = import("./types").StorageMeta;
|
|
28
|
+
export type SyncClient = import("./types").SyncClient;
|
|
29
|
+
export type SyncClientEvent = import("./types").SyncClientEvent;
|
|
30
|
+
export type SyncClientOptions = import("./types").SyncClientOptions;
|
|
31
|
+
export type SyncModelInstance<T = Record<string, unknown>> =
|
|
32
|
+
import("./types").SyncModelInstance<T>;
|
|
33
|
+
export type TransportAdapter = import("./types").TransportAdapter;
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type { MutateResult, Transaction } from "@strata-sync/core";
|
|
2
|
+
import {
|
|
3
|
+
createArchiveTransaction,
|
|
4
|
+
createDeleteTransaction,
|
|
5
|
+
createInsertTransaction,
|
|
6
|
+
createTransactionBatch,
|
|
7
|
+
createUnarchiveTransaction,
|
|
8
|
+
createUpdateTransaction,
|
|
9
|
+
} from "@strata-sync/core";
|
|
10
|
+
import type { StorageAdapter, TransportAdapter } from "./types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for the outbox manager
|
|
14
|
+
*/
|
|
15
|
+
export interface OutboxManagerOptions {
|
|
16
|
+
/** Storage adapter for persisting transactions */
|
|
17
|
+
storage: StorageAdapter;
|
|
18
|
+
/** Transport adapter for sending transactions */
|
|
19
|
+
transport: TransportAdapter;
|
|
20
|
+
/** Client ID */
|
|
21
|
+
clientId: string;
|
|
22
|
+
/** Batch mutations together */
|
|
23
|
+
batchMutations?: boolean;
|
|
24
|
+
/** Delay before sending batch (ms) */
|
|
25
|
+
batchDelay?: number;
|
|
26
|
+
/** Maximum batch size */
|
|
27
|
+
maxBatchSize?: number;
|
|
28
|
+
/** Callback when transaction state changes */
|
|
29
|
+
onTransactionStateChange?: (tx: Transaction) => void;
|
|
30
|
+
/** Callback when transaction is rejected by server */
|
|
31
|
+
onTransactionRejected?: (tx: Transaction) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Manages the outbox queue of pending transactions
|
|
36
|
+
*/
|
|
37
|
+
export class OutboxManager {
|
|
38
|
+
private readonly storage: StorageAdapter;
|
|
39
|
+
private readonly transport: TransportAdapter;
|
|
40
|
+
private readonly clientId: string;
|
|
41
|
+
private readonly batchMutations: boolean;
|
|
42
|
+
private readonly batchDelay: number;
|
|
43
|
+
private readonly maxBatchSize: number;
|
|
44
|
+
private readonly onTransactionStateChange?: (tx: Transaction) => void;
|
|
45
|
+
private readonly onTransactionRejected?: (tx: Transaction) => void;
|
|
46
|
+
|
|
47
|
+
private pendingBatch: Transaction[] = [];
|
|
48
|
+
private batchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
49
|
+
private processing = false;
|
|
50
|
+
private processingPromise: Promise<void> | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(options: OutboxManagerOptions) {
|
|
53
|
+
this.storage = options.storage;
|
|
54
|
+
this.transport = options.transport;
|
|
55
|
+
this.clientId = options.clientId;
|
|
56
|
+
this.batchMutations = options.batchMutations ?? true;
|
|
57
|
+
this.batchDelay = options.batchDelay ?? 50;
|
|
58
|
+
this.maxBatchSize = options.maxBatchSize ?? 100;
|
|
59
|
+
this.onTransactionStateChange = options.onTransactionStateChange;
|
|
60
|
+
this.onTransactionRejected = options.onTransactionRejected;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Queues an INSERT transaction
|
|
65
|
+
*/
|
|
66
|
+
async insert(
|
|
67
|
+
modelName: string,
|
|
68
|
+
modelId: string,
|
|
69
|
+
data: Record<string, unknown>
|
|
70
|
+
): Promise<Transaction> {
|
|
71
|
+
const tx = createInsertTransaction(this.clientId, modelName, modelId, data);
|
|
72
|
+
await this.queueTransaction(tx);
|
|
73
|
+
return tx;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Queues an UPDATE transaction
|
|
78
|
+
*/
|
|
79
|
+
async update(
|
|
80
|
+
modelName: string,
|
|
81
|
+
modelId: string,
|
|
82
|
+
changes: Record<string, unknown>,
|
|
83
|
+
original: Record<string, unknown>
|
|
84
|
+
): Promise<Transaction> {
|
|
85
|
+
const tx = createUpdateTransaction(
|
|
86
|
+
this.clientId,
|
|
87
|
+
modelName,
|
|
88
|
+
modelId,
|
|
89
|
+
changes,
|
|
90
|
+
original
|
|
91
|
+
);
|
|
92
|
+
await this.queueTransaction(tx);
|
|
93
|
+
return tx;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Queues a DELETE transaction
|
|
98
|
+
*/
|
|
99
|
+
async delete(
|
|
100
|
+
modelName: string,
|
|
101
|
+
modelId: string,
|
|
102
|
+
original: Record<string, unknown>
|
|
103
|
+
): Promise<Transaction> {
|
|
104
|
+
const tx = createDeleteTransaction(
|
|
105
|
+
this.clientId,
|
|
106
|
+
modelName,
|
|
107
|
+
modelId,
|
|
108
|
+
original
|
|
109
|
+
);
|
|
110
|
+
await this.queueTransaction(tx);
|
|
111
|
+
return tx;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Queues an ARCHIVE transaction
|
|
116
|
+
*/
|
|
117
|
+
async archive(
|
|
118
|
+
modelName: string,
|
|
119
|
+
modelId: string,
|
|
120
|
+
options: { original?: Record<string, unknown>; archivedAt?: string } = {}
|
|
121
|
+
): Promise<Transaction> {
|
|
122
|
+
const tx = createArchiveTransaction(
|
|
123
|
+
this.clientId,
|
|
124
|
+
modelName,
|
|
125
|
+
modelId,
|
|
126
|
+
options
|
|
127
|
+
);
|
|
128
|
+
await this.queueTransaction(tx);
|
|
129
|
+
return tx;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Queues an UNARCHIVE transaction
|
|
134
|
+
*/
|
|
135
|
+
async unarchive(
|
|
136
|
+
modelName: string,
|
|
137
|
+
modelId: string,
|
|
138
|
+
options: { original?: Record<string, unknown> } = {}
|
|
139
|
+
): Promise<Transaction> {
|
|
140
|
+
const tx = createUnarchiveTransaction(
|
|
141
|
+
this.clientId,
|
|
142
|
+
modelName,
|
|
143
|
+
modelId,
|
|
144
|
+
options
|
|
145
|
+
);
|
|
146
|
+
await this.queueTransaction(tx);
|
|
147
|
+
return tx;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Queues a transaction for sending
|
|
152
|
+
*/
|
|
153
|
+
private async queueTransaction(tx: Transaction): Promise<void> {
|
|
154
|
+
// Persist to storage first
|
|
155
|
+
await this.storage.addToOutbox(tx);
|
|
156
|
+
this.onTransactionStateChange?.(tx);
|
|
157
|
+
|
|
158
|
+
if (this.batchMutations) {
|
|
159
|
+
this.pendingBatch.push(tx);
|
|
160
|
+
this.scheduleBatchSend();
|
|
161
|
+
} else {
|
|
162
|
+
await this.sendBatch([tx]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Schedules sending the pending batch
|
|
168
|
+
*/
|
|
169
|
+
private scheduleBatchSend(): void {
|
|
170
|
+
if (this.batchTimer) {
|
|
171
|
+
clearTimeout(this.batchTimer);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Send immediately if batch is full
|
|
175
|
+
if (this.pendingBatch.length >= this.maxBatchSize) {
|
|
176
|
+
this.flushBatch();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.batchTimer = setTimeout(() => {
|
|
181
|
+
this.flushBatch();
|
|
182
|
+
}, this.batchDelay);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Flushes the pending batch
|
|
187
|
+
*/
|
|
188
|
+
private flushBatch(): void {
|
|
189
|
+
if (this.batchTimer) {
|
|
190
|
+
clearTimeout(this.batchTimer);
|
|
191
|
+
this.batchTimer = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (this.pendingBatch.length === 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const batch = this.pendingBatch;
|
|
199
|
+
this.pendingBatch = [];
|
|
200
|
+
|
|
201
|
+
this.sendBatch(batch).catch(() => {
|
|
202
|
+
// Errors are handled in sendBatch
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Sends a batch of transactions
|
|
208
|
+
*/
|
|
209
|
+
private async sendBatch(transactions: Transaction[]): Promise<void> {
|
|
210
|
+
if (transactions.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Mark transactions as sent
|
|
215
|
+
for (const tx of transactions) {
|
|
216
|
+
tx.state = "sent";
|
|
217
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
218
|
+
state: "sent",
|
|
219
|
+
});
|
|
220
|
+
this.onTransactionStateChange?.(tx);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const batch = createTransactionBatch(transactions);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const result = await this.transport.mutate(batch);
|
|
227
|
+
await this.handleMutateResult(transactions, result);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// Mark transactions as failed
|
|
230
|
+
for (const tx of transactions) {
|
|
231
|
+
tx.state = "failed";
|
|
232
|
+
tx.lastError = error instanceof Error ? error.message : "Unknown error";
|
|
233
|
+
tx.retryCount++;
|
|
234
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
235
|
+
state: "failed",
|
|
236
|
+
lastError: tx.lastError,
|
|
237
|
+
retryCount: tx.retryCount,
|
|
238
|
+
});
|
|
239
|
+
this.onTransactionStateChange?.(tx);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Handles the result of a mutation batch
|
|
247
|
+
*/
|
|
248
|
+
private async handleMutateResult(
|
|
249
|
+
transactions: Transaction[],
|
|
250
|
+
result: MutateResult
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const txMap = new Map(transactions.map((tx) => [tx.clientTxId, tx]));
|
|
253
|
+
const maxSyncId = Math.max(
|
|
254
|
+
result.lastSyncId ?? 0,
|
|
255
|
+
...result.results
|
|
256
|
+
.map((txResult) => txResult.syncId)
|
|
257
|
+
.filter((value): value is number => typeof value === "number")
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
for (const txResult of result.results) {
|
|
261
|
+
const tx = txMap.get(txResult.clientTxId);
|
|
262
|
+
if (!tx) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (txResult.success) {
|
|
267
|
+
const syncIdNeededForCompletion =
|
|
268
|
+
txResult.syncId ??
|
|
269
|
+
(Number.isFinite(maxSyncId) ? maxSyncId : undefined);
|
|
270
|
+
tx.state = "awaitingSync";
|
|
271
|
+
tx.syncIdNeededForCompletion = syncIdNeededForCompletion;
|
|
272
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
273
|
+
state: "awaitingSync",
|
|
274
|
+
syncIdNeededForCompletion,
|
|
275
|
+
});
|
|
276
|
+
} else {
|
|
277
|
+
tx.state = "failed";
|
|
278
|
+
tx.lastError = txResult.error ?? "Unknown error";
|
|
279
|
+
tx.retryCount++;
|
|
280
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
281
|
+
state: "failed",
|
|
282
|
+
lastError: tx.lastError,
|
|
283
|
+
retryCount: tx.retryCount,
|
|
284
|
+
});
|
|
285
|
+
this.onTransactionRejected?.(tx);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.onTransactionStateChange?.(tx);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Processes any pending transactions from storage (e.g., after reconnect)
|
|
294
|
+
*/
|
|
295
|
+
async processPendingTransactions(): Promise<void> {
|
|
296
|
+
if (this.processing) {
|
|
297
|
+
await this.processingPromise;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this.processing = true;
|
|
302
|
+
this.processingPromise = this.doProcessPending();
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await this.processingPromise;
|
|
306
|
+
} finally {
|
|
307
|
+
this.processing = false;
|
|
308
|
+
this.processingPromise = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async doProcessPending(): Promise<void> {
|
|
313
|
+
const pending = await this.storage.getOutbox();
|
|
314
|
+
|
|
315
|
+
// Reset sent transactions back to queued (they may not have been confirmed)
|
|
316
|
+
for (const tx of pending) {
|
|
317
|
+
if (tx.state === "sent") {
|
|
318
|
+
tx.state = "queued";
|
|
319
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
320
|
+
state: "queued",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Filter to only queued transactions
|
|
326
|
+
const queued = pending.filter(
|
|
327
|
+
(tx) => tx.state === "queued" && tx.retryCount < 5
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if (queued.length === 0) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Send in batches
|
|
335
|
+
for (let i = 0; i < queued.length; i += this.maxBatchSize) {
|
|
336
|
+
const batch = queued.slice(i, i + this.maxBatchSize);
|
|
337
|
+
await this.sendBatch(batch);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Gets the count of pending transactions
|
|
343
|
+
*/
|
|
344
|
+
async getPendingCount(): Promise<number> {
|
|
345
|
+
const outbox = await this.storage.getOutbox();
|
|
346
|
+
return outbox.filter(
|
|
347
|
+
(tx) =>
|
|
348
|
+
tx.state === "queued" ||
|
|
349
|
+
tx.state === "sent" ||
|
|
350
|
+
tx.state === "awaitingSync"
|
|
351
|
+
).length;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Completes any awaiting transactions up to the given sync ID
|
|
356
|
+
*/
|
|
357
|
+
async completeUpToSyncId(lastSyncId: number): Promise<number> {
|
|
358
|
+
const outbox = await this.storage.getOutbox();
|
|
359
|
+
let completed = 0;
|
|
360
|
+
|
|
361
|
+
for (const tx of outbox) {
|
|
362
|
+
if (
|
|
363
|
+
tx.state === "awaitingSync" &&
|
|
364
|
+
typeof tx.syncIdNeededForCompletion === "number" &&
|
|
365
|
+
tx.syncIdNeededForCompletion <= lastSyncId
|
|
366
|
+
) {
|
|
367
|
+
await this.storage.removeFromOutbox(tx.clientTxId);
|
|
368
|
+
tx.state = "completed";
|
|
369
|
+
completed++;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return completed;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Forces an immediate flush of pending batches
|
|
378
|
+
*/
|
|
379
|
+
async flush(): Promise<void> {
|
|
380
|
+
this.flushBatch();
|
|
381
|
+
await this.processingPromise;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Clears all pending transactions
|
|
386
|
+
*/
|
|
387
|
+
async clear(): Promise<void> {
|
|
388
|
+
if (this.batchTimer) {
|
|
389
|
+
clearTimeout(this.batchTimer);
|
|
390
|
+
this.batchTimer = null;
|
|
391
|
+
}
|
|
392
|
+
this.pendingBatch = [];
|
|
393
|
+
|
|
394
|
+
const outbox = await this.storage.getOutbox();
|
|
395
|
+
for (const tx of outbox) {
|
|
396
|
+
await this.storage.removeFromOutbox(tx.clientTxId);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|