@g9tm/syncdata 1.2.1

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.
Files changed (149) hide show
  1. package/LICENSE.md +21 -0
  2. package/PARITY.md +71 -0
  3. package/README.md +71 -0
  4. package/THIRD-PARTY-NOTICES.md +31 -0
  5. package/dist/abstractions/digest.d.ts +27 -0
  6. package/dist/abstractions/digest.js +78 -0
  7. package/dist/abstractions/digest.js.map +1 -0
  8. package/dist/abstractions/enums.d.ts +42 -0
  9. package/dist/abstractions/enums.js +60 -0
  10. package/dist/abstractions/enums.js.map +1 -0
  11. package/dist/abstractions/errors.d.ts +72 -0
  12. package/dist/abstractions/errors.js +90 -0
  13. package/dist/abstractions/errors.js.map +1 -0
  14. package/dist/abstractions/guid.d.ts +24 -0
  15. package/dist/abstractions/guid.js +93 -0
  16. package/dist/abstractions/guid.js.map +1 -0
  17. package/dist/abstractions/key.d.ts +16 -0
  18. package/dist/abstractions/key.js +47 -0
  19. package/dist/abstractions/key.js.map +1 -0
  20. package/dist/abstractions/long.d.ts +103 -0
  21. package/dist/abstractions/long.js +351 -0
  22. package/dist/abstractions/long.js.map +1 -0
  23. package/dist/abstractions/parameters.d.ts +49 -0
  24. package/dist/abstractions/parameters.js +153 -0
  25. package/dist/abstractions/parameters.js.map +1 -0
  26. package/dist/brotli-js/decode.d.ts +8 -0
  27. package/dist/brotli-js/decode.js +2004 -0
  28. package/dist/brotli-js/decode.js.map +1 -0
  29. package/dist/brotli-js/index.d.ts +5 -0
  30. package/dist/brotli-js/index.js +19 -0
  31. package/dist/brotli-js/index.js.map +1 -0
  32. package/dist/client/engine.d.ts +110 -0
  33. package/dist/client/engine.js +1158 -0
  34. package/dist/client/engine.js.map +1 -0
  35. package/dist/client/model.d.ts +61 -0
  36. package/dist/client/model.js +154 -0
  37. package/dist/client/model.js.map +1 -0
  38. package/dist/client/progress.d.ts +101 -0
  39. package/dist/client/progress.js +251 -0
  40. package/dist/client/progress.js.map +1 -0
  41. package/dist/client/runs.d.ts +219 -0
  42. package/dist/client/runs.js +239 -0
  43. package/dist/client/runs.js.map +1 -0
  44. package/dist/client/storeTypes.d.ts +428 -0
  45. package/dist/client/storeTypes.js +256 -0
  46. package/dist/client/storeTypes.js.map +1 -0
  47. package/dist/client/transport.d.ts +19 -0
  48. package/dist/client/transport.js +2 -0
  49. package/dist/client/transport.js.map +1 -0
  50. package/dist/index.d.ts +27 -0
  51. package/dist/index.js +35 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/internal/abort.d.ts +21 -0
  54. package/dist/internal/abort.js +125 -0
  55. package/dist/internal/abort.js.map +1 -0
  56. package/dist/internal/base64.d.ts +8 -0
  57. package/dist/internal/base64.js +85 -0
  58. package/dist/internal/base64.js.map +1 -0
  59. package/dist/internal/dotnetFormat.d.ts +32 -0
  60. package/dist/internal/dotnetFormat.js +207 -0
  61. package/dist/internal/dotnetFormat.js.map +1 -0
  62. package/dist/internal/md5.d.ts +2 -0
  63. package/dist/internal/md5.js +97 -0
  64. package/dist/internal/md5.js.map +1 -0
  65. package/dist/internal/sha256.d.ts +20 -0
  66. package/dist/internal/sha256.js +153 -0
  67. package/dist/internal/sha256.js.map +1 -0
  68. package/dist/internal/utf8.d.ts +8 -0
  69. package/dist/internal/utf8.js +143 -0
  70. package/dist/internal/utf8.js.map +1 -0
  71. package/dist/node/index.d.ts +9 -0
  72. package/dist/node/index.js +35 -0
  73. package/dist/node/index.js.map +1 -0
  74. package/dist/node/sqliteDriver.d.ts +11 -0
  75. package/dist/node/sqliteDriver.js +252 -0
  76. package/dist/node/sqliteDriver.js.map +1 -0
  77. package/dist/protocol/dtos.d.ts +280 -0
  78. package/dist/protocol/dtos.js +33 -0
  79. package/dist/protocol/dtos.js.map +1 -0
  80. package/dist/protocol/protocol.d.ts +36 -0
  81. package/dist/protocol/protocol.js +63 -0
  82. package/dist/protocol/protocol.js.map +1 -0
  83. package/dist/protocol/rowBatch/columnLayout.d.ts +4 -0
  84. package/dist/protocol/rowBatch/columnLayout.js +309 -0
  85. package/dist/protocol/rowBatch/columnLayout.js.map +1 -0
  86. package/dist/protocol/rowBatch/format.d.ts +74 -0
  87. package/dist/protocol/rowBatch/format.js +163 -0
  88. package/dist/protocol/rowBatch/format.js.map +1 -0
  89. package/dist/protocol/rowBatch/reader.d.ts +119 -0
  90. package/dist/protocol/rowBatch/reader.js +459 -0
  91. package/dist/protocol/rowBatch/reader.js.map +1 -0
  92. package/dist/protocol/rowBatch/writer.d.ts +101 -0
  93. package/dist/protocol/rowBatch/writer.js +412 -0
  94. package/dist/protocol/rowBatch/writer.js.map +1 -0
  95. package/dist/protocol/wire.d.ts +32 -0
  96. package/dist/protocol/wire.js +329 -0
  97. package/dist/protocol/wire.js.map +1 -0
  98. package/dist/signalr/index.d.ts +2 -0
  99. package/dist/signalr/index.js +5 -0
  100. package/dist/signalr/index.js.map +1 -0
  101. package/dist/signalr/protocols.d.ts +17 -0
  102. package/dist/signalr/protocols.js +109 -0
  103. package/dist/signalr/protocols.js.map +1 -0
  104. package/dist/signalr/transport.d.ts +67 -0
  105. package/dist/signalr/transport.js +286 -0
  106. package/dist/signalr/transport.js.map +1 -0
  107. package/dist/sqlite/apply.d.ts +91 -0
  108. package/dist/sqlite/apply.js +657 -0
  109. package/dist/sqlite/apply.js.map +1 -0
  110. package/dist/sqlite/commands.d.ts +24 -0
  111. package/dist/sqlite/commands.js +167 -0
  112. package/dist/sqlite/commands.js.map +1 -0
  113. package/dist/sqlite/connection.d.ts +81 -0
  114. package/dist/sqlite/connection.js +322 -0
  115. package/dist/sqlite/connection.js.map +1 -0
  116. package/dist/sqlite/driver.d.ts +63 -0
  117. package/dist/sqlite/driver.js +9 -0
  118. package/dist/sqlite/driver.js.map +1 -0
  119. package/dist/sqlite/index.d.ts +6 -0
  120. package/dist/sqlite/index.js +7 -0
  121. package/dist/sqlite/index.js.map +1 -0
  122. package/dist/sqlite/internals.d.ts +63 -0
  123. package/dist/sqlite/internals.js +194 -0
  124. package/dist/sqlite/internals.js.map +1 -0
  125. package/dist/sqlite/options.d.ts +78 -0
  126. package/dist/sqlite/options.js +78 -0
  127. package/dist/sqlite/options.js.map +1 -0
  128. package/dist/sqlite/push.d.ts +9 -0
  129. package/dist/sqlite/push.js +272 -0
  130. package/dist/sqlite/push.js.map +1 -0
  131. package/dist/sqlite/query.d.ts +38 -0
  132. package/dist/sqlite/query.js +476 -0
  133. package/dist/sqlite/query.js.map +1 -0
  134. package/dist/sqlite/revocation.d.ts +28 -0
  135. package/dist/sqlite/revocation.js +337 -0
  136. package/dist/sqlite/revocation.js.map +1 -0
  137. package/dist/sqlite/sql.d.ts +60 -0
  138. package/dist/sqlite/sql.js +324 -0
  139. package/dist/sqlite/sql.js.map +1 -0
  140. package/dist/sqlite/store.d.ts +116 -0
  141. package/dist/sqlite/store.js +664 -0
  142. package/dist/sqlite/store.js.map +1 -0
  143. package/dist/sqlite/values.d.ts +29 -0
  144. package/dist/sqlite/values.js +288 -0
  145. package/dist/sqlite/values.js.map +1 -0
  146. package/dist/testing/index.d.ts +61 -0
  147. package/dist/testing/index.js +175 -0
  148. package/dist/testing/index.js.map +1 -0
  149. package/package.json +104 -0
@@ -0,0 +1,1158 @@
1
+ // Twin of G9SyncData.Client/G9CSyncEngine.cs, .Execution.cs and .Reconcile.cs: one engine per local database, with a
2
+ // prioritised run queue (status queries never wait for a running sync) and background runs that yield to user runs.
3
+ import { G9EMoveOutPolicy, G9ESyncDirection } from '../abstractions/enums.js';
4
+ import { G9SyncErrorCodes, G9SyncException, isSyncException } from '../abstractions/errors.js';
5
+ import { guidToN, newGuid } from '../abstractions/guid.js';
6
+ import { G9SyncParameters, compareOrdinal } from '../abstractions/parameters.js';
7
+ import { delay, linkSignals, onAbort } from '../internal/abort.js';
8
+ import { sha256, toHex } from '../internal/sha256.js';
9
+ import { utf8Encode } from '../internal/utf8.js';
10
+ import { G9ESubscriptionAction, syncErrorToException, } from '../protocol/dtos.js';
11
+ import { openRowBatch } from '../protocol/rowBatch/reader.js';
12
+ import { G9ESyncPhase, G9SyncProgressTracker, phaseName } from './progress.js';
13
+ import { G9ESyncOutcome, G9ESyncPriority, G9SyncRun, canCoalesce, describeModelProblem, isTransientError, makeRunResult, normalizeError, outcomeName, priorityName, requestSignature, resolveEngineOptions, resolveRequest, } from './runs.js';
14
+ import { G9ApplyReport, G9ESubscriptionState, toSubscriptionRef, } from './storeTypes.js';
15
+ const STAT_CONNECTING = 'Connecting';
16
+ const STAT_PLANNING = 'Planning';
17
+ const STAT_PUSH_PER_K_ROW = 'PushPerKRow';
18
+ const STAT_DOWNLOAD_PER_K_ROW = 'DownloadPerKRow';
19
+ const STAT_APPLY_PER_K_ROW = 'ApplyPerKRow';
20
+ const STAT_RECONCILE_PER_SUBSCRIPTION = 'ReconcilePerSubscription';
21
+ /**
22
+ * The client sync engine (`G9CSyncEngine`): one per local database. Owns a prioritised run queue, so status queries
23
+ * never wait for a running sync and background runs yield to user runs.
24
+ */
25
+ export class G9SyncEngine {
26
+ constructor(store, transport, model, options) {
27
+ this._lifetime = linkSignals();
28
+ this._queue = [];
29
+ this._pokedTables = {};
30
+ this._rowsAppliedListeners = [];
31
+ this._runCompletedListeners = [];
32
+ this._initialized = false;
33
+ this._initializing = null;
34
+ this._clientId = '';
35
+ this._order = 0;
36
+ this._current = null;
37
+ this._suppressions = 0;
38
+ this._autoTimer = null;
39
+ this._pokeTimer = null;
40
+ this._wake = null;
41
+ this._worker = null;
42
+ this._disposed = false;
43
+ /** The model problems last logged, so the warning is said once per distinct set (ADR-0061). */
44
+ this._reportedModelProblems = null;
45
+ if (!store)
46
+ throw new TypeError('A store is required.');
47
+ if (!transport)
48
+ throw new TypeError('A transport is required.');
49
+ if (!model)
50
+ throw new TypeError('A model is required.');
51
+ this._store = store;
52
+ this._transport = transport;
53
+ this.model = model;
54
+ this._options = resolveEngineOptions(options);
55
+ this._unsubscribePokes = transport.onChangesAvailable((changes) => this._onChangesAvailable(changes));
56
+ }
57
+ get clientId() {
58
+ if (!this._initialized)
59
+ throw new Error('Call initialize first.');
60
+ return this._clientId;
61
+ }
62
+ get isRunning() {
63
+ return this._current !== null;
64
+ }
65
+ get queuedRuns() {
66
+ return this._queue.length;
67
+ }
68
+ get isBackgroundSuppressed() {
69
+ return this._suppressions > 0;
70
+ }
71
+ /** Raised after rows were written locally (download, reconciliation or canonical push results). Returns the unsubscribe function. */
72
+ onRowsApplied(listener) {
73
+ this._rowsAppliedListeners.push(listener);
74
+ return () => remove(this._rowsAppliedListeners, listener);
75
+ }
76
+ onRunCompleted(listener) {
77
+ this._runCompletedListeners.push(listener);
78
+ return () => remove(this._runCompletedListeners, listener);
79
+ }
80
+ /** Provisions local tables and capture, and starts the run worker. Idempotent. */
81
+ initialize(signal) {
82
+ if (this._initialized)
83
+ return Promise.resolve();
84
+ if (!this._initializing) {
85
+ this._initializing = (async () => {
86
+ try {
87
+ await this._store.initialize(this.model, signal);
88
+ this._clientId = await this._store.getClientId(signal);
89
+ this._initialized = true;
90
+ this._worker = this._workerLoop();
91
+ }
92
+ finally {
93
+ if (!this._initialized)
94
+ this._initializing = null;
95
+ }
96
+ })();
97
+ }
98
+ return this._initializing;
99
+ }
100
+ // ================================================================ subscriptions
101
+ /** Adds (or returns) a subscription. Its rows arrive on the next run that includes it. */
102
+ async subscribe(name, parameters, options, signal) {
103
+ var _a, _b, _c, _d, _e;
104
+ if (typeof name !== 'string' || name.trim() === '')
105
+ throw new TypeError('A subscription name is required.');
106
+ await this.initialize(signal);
107
+ const state = await this._store.getServerState(signal);
108
+ const definition = (_a = state.definitions.find((d) => d.name === name)) !== null && _a !== void 0 ? _a : null;
109
+ const ephemeral = (_c = (_b = options === null || options === void 0 ? void 0 : options.ephemeral) !== null && _b !== void 0 ? _b : definition === null || definition === void 0 ? void 0 : definition.ephemeral) !== null && _c !== void 0 ? _c : false;
110
+ const ttlMs = (_d = options === null || options === void 0 ? void 0 : options.ttlMs) !== null && _d !== void 0 ? _d : (definition && definition.ephemeral && definition.ttlSeconds > 0 ? definition.ttlSeconds * 1000 : null);
111
+ const record = await this._store.ensureSubscription(name, parameters !== null && parameters !== void 0 ? parameters : new G9SyncParameters(), ephemeral, ttlMs, (_e = options === null || options === void 0 ? void 0 : options.adoptExistingRows) !== null && _e !== void 0 ? _e : false, signal);
112
+ await this._store.updateSubscription(record.key, { accessed: true }, signal);
113
+ return record;
114
+ }
115
+ /** Removes a subscription; rows no other subscription holds follow the server's move-out policy. */
116
+ async unsubscribe(name, parameters, signal) {
117
+ var _a, _b;
118
+ await this.initialize(signal);
119
+ const state = await this._store.getServerState(signal);
120
+ const policy = (_b = (_a = state.definitions.find((d) => d.name === name)) === null || _a === void 0 ? void 0 : _a.moveOutPolicy) !== null && _b !== void 0 ? _b : G9EMoveOutPolicy.Delete;
121
+ await this._store.removeSubscription(G9SyncParameters.buildSubscriptionKey(name, parameters !== null && parameters !== void 0 ? parameters : null), policy, signal);
122
+ }
123
+ async getSubscriptions(signal) {
124
+ await this.initialize(signal);
125
+ return this._store.getSubscriptions(signal);
126
+ }
127
+ // ================================================================ runs
128
+ /** Queues a run. Identical plain requests already waiting are coalesced into one run. */
129
+ run(request) {
130
+ if (!this._initialized)
131
+ throw new Error('Call initialize first.');
132
+ const resolved = resolveRequest(request);
133
+ if (canCoalesce(resolved)) {
134
+ const signature = requestSignature(resolved);
135
+ for (const queued of this._queue) {
136
+ const r = queued.run;
137
+ if (!r.isStarted && canCoalesce(r.request) && r.request.priority === resolved.priority && requestSignature(r.request) === signature)
138
+ return r;
139
+ }
140
+ }
141
+ const run = new G9SyncRun(resolved);
142
+ this._queue.push({ run, priority: resolved.priority, order: this._order++ });
143
+ const current = this._current;
144
+ let preempted = null;
145
+ if (current !== null && current.request.priority === G9ESyncPriority.Background && resolved.priority > G9ESyncPriority.Background) {
146
+ current.preempted = true;
147
+ preempted = current;
148
+ }
149
+ this._signal();
150
+ if (preempted) {
151
+ preempted.cancel();
152
+ this._log('information', 20001, `Background run ${preempted.runId} pre-empted by run ${run.runId}`);
153
+ }
154
+ return run;
155
+ }
156
+ /** Queues a run and awaits its result. */
157
+ sync(request) {
158
+ return this.run(request).completion;
159
+ }
160
+ /** While the returned scope is alive, background runs are skipped. Call the returned function to end it. */
161
+ suppressBackground() {
162
+ this._suppressions++;
163
+ let done = false;
164
+ return () => {
165
+ if (done)
166
+ return;
167
+ done = true;
168
+ this._suppressions--;
169
+ };
170
+ }
171
+ /** Queues a background push+pull of every non-heavy subscription on a fixed interval. */
172
+ startAutomaticSync(intervalMs) {
173
+ if (!(intervalMs > 0))
174
+ throw new RangeError('The interval must be positive.');
175
+ this.stopAutomaticSync();
176
+ this._autoTimer = setInterval(() => this._tryQueueBackground(null), intervalMs);
177
+ }
178
+ stopAutomaticSync() {
179
+ if (this._autoTimer !== null) {
180
+ clearInterval(this._autoTimer);
181
+ this._autoTimer = null;
182
+ }
183
+ }
184
+ // ================================================================ queries
185
+ /**
186
+ * Records a command (ADR-0025): the server executes the handler registered for `name` on the next push, once, in
187
+ * device order. `applyLocally` writes its local effects immediately, in the same transaction.
188
+ */
189
+ async executeCommand(name, payloadJson, applyLocally, version = 1, signal) {
190
+ if (typeof name !== 'string' || name.trim() === '')
191
+ throw new TypeError('A command name is required.');
192
+ if (typeof payloadJson !== 'string')
193
+ throw new TypeError('The payload JSON is required.');
194
+ await this.initialize(signal);
195
+ return this._store.enqueueCommand({ name, version, payload: payloadJson }, applyLocally !== null && applyLocally !== void 0 ? applyLocally : null, signal);
196
+ }
197
+ /** Frees space taken by an UPLOAD-ONLY table: removes uploaded rows without uploading a delete. */
198
+ async forgetUploadedRows(localTable, keys, signal) {
199
+ await this.initialize(signal);
200
+ return this._store.forgetUploadedRows(localTable, keys !== null && keys !== void 0 ? keys : null, signal);
201
+ }
202
+ async getCommands(state, limit = 200, signal) {
203
+ await this.initialize(signal);
204
+ return this._store.getCommands(state !== null && state !== void 0 ? state : null, limit, signal);
205
+ }
206
+ async getPendingSummary(signal) {
207
+ await this.initialize(signal);
208
+ return this._store.getPendingSummary(signal);
209
+ }
210
+ async getRejections(signal) {
211
+ await this.initialize(signal);
212
+ return this._store.getRejections(signal);
213
+ }
214
+ async getQuarantined(signal) {
215
+ await this.initialize(signal);
216
+ return this._store.getQuarantined(signal);
217
+ }
218
+ async restoreQuarantined(localTable, key, signal) {
219
+ await this.initialize(signal);
220
+ return this._store.restoreQuarantined(localTable, key, signal);
221
+ }
222
+ /** Throws away every staged pull, so the next run plans afresh. Returns whether there was one. */
223
+ async discardStagedPull(signal) {
224
+ await this.initialize(signal);
225
+ return (await this._store.discardAllStagedPulls(signal)) > 0;
226
+ }
227
+ async discardQuarantined(localTable, key, signal) {
228
+ await this.initialize(signal);
229
+ return this._store.discardQuarantined(localTable !== null && localTable !== void 0 ? localTable : null, key !== null && key !== void 0 ? key : null, signal);
230
+ }
231
+ async getRetained(signal) {
232
+ await this.initialize(signal);
233
+ return this._store.getRetained(signal);
234
+ }
235
+ async retryRejected(localTable, key, signal) {
236
+ await this.initialize(signal);
237
+ return this._store.retryRejected(localTable !== null && localTable !== void 0 ? localTable : null, key !== null && key !== void 0 ? key : null, signal);
238
+ }
239
+ async readChangeFeed(localTable, afterSequence, limit = 1000, signal) {
240
+ await this.initialize(signal);
241
+ return this._store.readChangeFeed(localTable, afterSequence, limit, signal);
242
+ }
243
+ /** The highest sequence readChangeFeed can return today (a watermark above it belongs to a store that no longer exists). */
244
+ async getChangeSequence(signal) {
245
+ await this.initialize(signal);
246
+ return this._store.getChangeSequence(signal);
247
+ }
248
+ /** The highest sequence ever purged from the change feed; 0 when nothing was (ADR-0059). */
249
+ async getChangeFeedFloor(signal) {
250
+ await this.initialize(signal);
251
+ return this._store.getChangeFeedFloor(signal);
252
+ }
253
+ async adoptExistingRows(signal) {
254
+ await this.initialize(signal);
255
+ return this._store.adoptExistingRows(signal);
256
+ }
257
+ async markDirty(localTable, keys, signal) {
258
+ await this.initialize(signal);
259
+ return this._store.markDirty(localTable, keys, signal);
260
+ }
261
+ async getHealth(signal) {
262
+ await this.initialize(signal);
263
+ const state = await this._store.getServerState(signal);
264
+ return {
265
+ clientId: this._clientId,
266
+ isConnected: this._transport.isConnected,
267
+ isRunning: this.isRunning,
268
+ queuedRuns: this.queuedRuns,
269
+ serverEpoch: state.epoch,
270
+ lastHelloUtcMs: state.lastHelloUtcMs,
271
+ pending: await this._store.getPendingSummary(signal),
272
+ subscriptions: await this._store.getSubscriptions(signal),
273
+ recentRuns: await this._store.getRecentRunReports(10, signal),
274
+ };
275
+ }
276
+ async dispose() {
277
+ var _a;
278
+ if (this._disposed)
279
+ return;
280
+ this._disposed = true;
281
+ this.stopAutomaticSync();
282
+ this._unsubscribePokes();
283
+ this._lifetime.abort();
284
+ const abandoned = this._queue.splice(0, this._queue.length);
285
+ for (const { run } of abandoned) {
286
+ run.complete(makeRunResult({ runId: run.runId, outcome: G9ESyncOutcome.Canceled, error: new G9SyncException(G9SyncErrorCodes.Canceled, 'The sync engine was disposed.') }));
287
+ }
288
+ (_a = this._current) === null || _a === void 0 ? void 0 : _a.cancel();
289
+ this._signal();
290
+ if (this._worker) {
291
+ try {
292
+ await this._worker;
293
+ }
294
+ catch {
295
+ // shutting down
296
+ }
297
+ }
298
+ if (this._pokeTimer !== null) {
299
+ clearTimeout(this._pokeTimer);
300
+ this._pokeTimer = null;
301
+ }
302
+ }
303
+ // ================================================================ worker
304
+ _signal() {
305
+ const wake = this._wake;
306
+ this._wake = null;
307
+ if (wake)
308
+ wake();
309
+ }
310
+ _dequeue() {
311
+ if (this._queue.length === 0)
312
+ return null;
313
+ let best = 0;
314
+ for (let i = 1; i < this._queue.length; i++) {
315
+ const a = this._queue[i];
316
+ const b = this._queue[best];
317
+ if (a.priority > b.priority || (a.priority === b.priority && a.order < b.order))
318
+ best = i;
319
+ }
320
+ return this._queue.splice(best, 1)[0].run;
321
+ }
322
+ async _workerLoop() {
323
+ while (!this._lifetime.signal.aborted) {
324
+ const run = this._dequeue();
325
+ if (!run) {
326
+ await new Promise((resolve) => {
327
+ this._wake = resolve;
328
+ });
329
+ continue;
330
+ }
331
+ run.isStarted = true;
332
+ this._current = run;
333
+ try {
334
+ await this._executeRun(run);
335
+ }
336
+ catch (exception) {
337
+ this._log('error', 20002, `Unexpected failure executing run ${run.runId}`, exception);
338
+ run.complete(makeRunResult({ runId: run.runId, outcome: G9ESyncOutcome.Failed, error: normalizeError(exception) }));
339
+ }
340
+ finally {
341
+ this._current = null;
342
+ }
343
+ // A pre-empted background run still owes its work: queue it again behind the run that pre-empted it.
344
+ if (run.preempted && run.request.priority === G9ESyncPriority.Background)
345
+ this._tryQueueBackground(run.request.subscriptions);
346
+ }
347
+ }
348
+ _tryQueueBackground(subscriptions) {
349
+ if (!this._initialized || this.isBackgroundSuppressed || (this._options.hooks && !this._options.hooks.isNetworkAvailable) || this._lifetime.signal.aborted) {
350
+ return;
351
+ }
352
+ this.run({ priority: G9ESyncPriority.Background, subscriptions });
353
+ }
354
+ _onChangesAvailable(payload) {
355
+ if (!this._options.pullOnChangesAvailable || payload.tables.length === 0)
356
+ return;
357
+ for (const table of payload.tables)
358
+ this._pokedTables[table.toLowerCase()] = table;
359
+ if (this._pokeTimer === null)
360
+ this._pokeTimer = setTimeout(() => this._flushPokes(), this._options.changesDebounceMs);
361
+ }
362
+ _flushPokes() {
363
+ const tables = Object.keys(this._pokedTables);
364
+ for (const key of tables)
365
+ delete this._pokedTables[key];
366
+ this._pokeTimer = null;
367
+ if (tables.length === 0 || !this._initialized)
368
+ return;
369
+ const signal = this._lifetime.signal;
370
+ void (async () => {
371
+ try {
372
+ const state = await this._store.getServerState(signal);
373
+ const names = {};
374
+ let any = false;
375
+ for (const d of state.definitions) {
376
+ if (!d.heavy && d.tables.some((t) => tables.indexOf(t.toLowerCase()) >= 0)) {
377
+ names[d.name] = true;
378
+ any = true;
379
+ }
380
+ }
381
+ if (!any)
382
+ return;
383
+ const keys = (await this._store.getSubscriptions(signal)).filter((s) => names[s.name] === true).map((s) => s.key);
384
+ if (keys.length > 0)
385
+ this._tryQueueBackground(keys);
386
+ }
387
+ catch (exception) {
388
+ if (!signal.aborted)
389
+ this._log('warning', 20003, 'Handling a server change notification failed', exception);
390
+ }
391
+ })();
392
+ }
393
+ _raiseRowsApplied(report, source, slice = null) {
394
+ for (const listener of this._rowsAppliedListeners.slice()) {
395
+ try {
396
+ listener({ report, source, slice });
397
+ }
398
+ catch (exception) {
399
+ this._log('warning', 20004, 'A RowsApplied handler threw', exception);
400
+ }
401
+ }
402
+ }
403
+ _log(level, eventId, message, error) {
404
+ const logger = this._options.logger;
405
+ if (!logger)
406
+ return;
407
+ try {
408
+ logger.log(level, eventId, message, error);
409
+ }
410
+ catch {
411
+ // A logger must never break the sync.
412
+ }
413
+ }
414
+ // ================================================================ execution
415
+ async _executeRun(run) {
416
+ const request = run.request;
417
+ const startedUtcMs = Date.now();
418
+ const changes = new G9ApplyReport();
419
+ const problems = [];
420
+ let modelProblems = [];
421
+ const synced = [];
422
+ let pushed = 0;
423
+ let rejected = 0;
424
+ let conflicts = 0;
425
+ let downloaded = 0;
426
+ let downloadedBytes = 0;
427
+ let reconciled = 0;
428
+ let error = null;
429
+ let outcome = G9ESyncOutcome.Succeeded;
430
+ const tracker = new G9SyncProgressTracker(run.runId, (p) => run.publish(p), () => this.queuedRuns, this._options.progressMaxHz);
431
+ try {
432
+ if (request.priority === G9ESyncPriority.Background && this.isBackgroundSuppressed) {
433
+ tracker.complete(G9ESyncPhase.Canceled);
434
+ run.complete(makeRunResult({ runId: run.runId, outcome: G9ESyncOutcome.Canceled, error: new G9SyncException(G9SyncErrorCodes.Canceled, 'Background sync is suppressed.') }));
435
+ return;
436
+ }
437
+ const linked = linkSignals(run.cancellation.signal, this._lifetime.signal);
438
+ const ct = linked.signal;
439
+ const cancelRegistration = onAbort(ct, () => tracker.setCancelPending());
440
+ let hostScope = null;
441
+ try {
442
+ const history = await this._store.getPhaseStatistics(ct);
443
+ const pending = request.push ? await this._store.getPendingSummary(ct) : null;
444
+ this._planPhases(tracker, history, request, pending ? pending.totalRows : 0);
445
+ if (this._options.hooks)
446
+ hostScope = await this._options.hooks.beginRun(run, ct);
447
+ // ---------------------------------------------------------------- 1. connect + hello
448
+ tracker.enter(G9ESyncPhase.Connecting);
449
+ await this._ensureConnected(ct);
450
+ await this._store.collectExpiredEphemeral(Date.now(), ct);
451
+ const subscriptions = await this._store.getSubscriptions(ct);
452
+ const hello = await this._withRetry((token) => this._transport.hello({
453
+ clientId: this._clientId,
454
+ appVersion: this._options.appVersion,
455
+ protocolVersion: 1,
456
+ tables: this.model.tables.map((t) => t.toWire()),
457
+ subscriptions: subscriptions.map(toSubscriptionRef),
458
+ }, token), ct);
459
+ if (hello.error)
460
+ throw syncErrorToException(hello.error);
461
+ await this._store.saveHello(hello, ct);
462
+ if (hello.unknownTables.length > 0) {
463
+ this._log('warning', 20016, 'The server does not know these client tables (they will not sync): ' + hello.unknownTables.join(', '));
464
+ }
465
+ // A column declared with the wrong type syncs in NEITHER direction (ADR-0061): say it on every run's result, and once
466
+ // per distinct set in the log.
467
+ modelProblems = [];
468
+ for (const table of hello.tables) {
469
+ for (const column of table.typeMismatchColumns) {
470
+ modelProblems.push({ code: G9SyncErrorCodes.ModelTypeMismatch, table: table.name, column: column.name, clientType: column.clientType, serverType: column.serverType });
471
+ }
472
+ }
473
+ if (modelProblems.length > 0) {
474
+ const described = modelProblems.map(describeModelProblem).join('; ');
475
+ if (this._reportedModelProblems !== described) {
476
+ this._reportedModelProblems = described;
477
+ this._log('warning', 20022, `The client model declares ${modelProblems.length} column(s) with a type the server does not have; they sync in NEITHER direction until the model is fixed: ${described}`);
478
+ }
479
+ if (this._options.failOnTypeMismatch) {
480
+ throw new G9SyncException(G9SyncErrorCodes.ModelTypeMismatch, 'The client model declares column types the server does not have; those columns would not sync: ' + described);
481
+ }
482
+ }
483
+ const negotiated = hello.tables;
484
+ const definitions = {};
485
+ for (const d of hello.definitions)
486
+ definitions[d.name] = d;
487
+ const states = {};
488
+ for (const state of hello.subscriptions)
489
+ states[state.key] = state;
490
+ // ---------------------------------------------------------------- 2. push
491
+ // A FAILED PUSH MUST NOT COST THE PULL: the failure is rethrown after the pull, never swallowed.
492
+ let pushFailure = null;
493
+ if (request.push && pending && (pending.totalRows > 0 || pending.pendingCommands > 0)) {
494
+ tracker.enter(G9ESyncPhase.Pushing, pending.totalRows + pending.pendingCommands);
495
+ try {
496
+ const totals = await this._pushAll(negotiated, subscriptions, hello.maxPushRows, hello.maxPushBytes, tracker, changes, ct);
497
+ pushed = totals.pushed;
498
+ rejected = totals.rejected;
499
+ conflicts = totals.conflicts;
500
+ }
501
+ catch (exception) {
502
+ if (ct.aborted)
503
+ throw exception;
504
+ this._log('warning', 20019, `Run ${run.runId}: the push failed; continuing to the pull so the device keeps receiving, then reporting the failure`, exception);
505
+ pushFailure = { error: exception };
506
+ }
507
+ }
508
+ if (request.pull) {
509
+ // ------------------------------------------------------------ 3. select subscriptions (+ dependencies)
510
+ const selected = await this._selectSubscriptions(request, subscriptions, definitions, problems, ct);
511
+ const usable = [];
512
+ for (const subscription of selected) {
513
+ const state = states[subscription.key];
514
+ if (state &&
515
+ (state.action === G9ESubscriptionAction.Unknown || state.action === G9ESubscriptionAction.InvalidParameters || state.action === G9ESubscriptionAction.Forbidden)) {
516
+ problems.push(state);
517
+ await this._store.updateSubscription(subscription.key, { state: G9ESubscriptionState.Failed, errorCode: actionCode(state.action) }, ct);
518
+ // Forbidden: these rows are no longer this caller's to hold (ADR-0048).
519
+ if (state.action === G9ESubscriptionAction.Forbidden) {
520
+ const released = await this._store.releaseSubscriptionRows(subscription.key, ct);
521
+ changes.merge(released);
522
+ if (released.total > 0) {
523
+ this._log('warning', 20018, `Subscription ${subscription.key} is forbidden; released its rows (${released.revokedDeleted} deleted, ${released.revokedQuarantined} quarantined, ${released.revokedRetained} retained)`);
524
+ this._raiseRowsApplied(released, G9ESyncPhase.Reconciling);
525
+ }
526
+ }
527
+ continue;
528
+ }
529
+ usable.push(subscription);
530
+ }
531
+ // ------------------------------------------------------------ 4. reconcile
532
+ const now = Date.now();
533
+ const toReconcile = usable.filter((s) => needsReconcile(s, request, states, definitions, now));
534
+ tracker.expect(G9ESyncPhase.Reconciling, toReconcile.length * historyOf(history, STAT_RECONCILE_PER_SUBSCRIPTION, 2500));
535
+ if (toReconcile.length > 0) {
536
+ tracker.enter(G9ESyncPhase.Reconciling, toReconcile.length);
537
+ let done = 0;
538
+ for (const subscription of toReconcile) {
539
+ try {
540
+ const report = await this._reconcileSubscription(subscription, definitions[subscription.name], negotiated, ct);
541
+ changes.merge(report);
542
+ reconciled += report.total;
543
+ if (report.total > 0)
544
+ this._raiseRowsApplied(report, G9ESyncPhase.Reconciling);
545
+ }
546
+ catch (exception) {
547
+ if (isSyncException(exception) && !exception.retryable && exception.code !== G9SyncErrorCodes.Canceled) {
548
+ problems.push({ key: subscription.key, action: G9ESubscriptionAction.Reconcile, message: exception.message });
549
+ await this._store.updateSubscription(subscription.key, { state: G9ESubscriptionState.Failed, errorCode: exception.code }, ct);
550
+ }
551
+ else {
552
+ throw exception;
553
+ }
554
+ }
555
+ tracker.report(++done, -1, null, subscription.key);
556
+ }
557
+ }
558
+ // ------------------------------------------------------------ 5. plan (or resume a staged pull)
559
+ tracker.enter(G9ESyncPhase.Planning);
560
+ const failedKeys = {};
561
+ for (const p of problems)
562
+ failedKeys[p.key] = true;
563
+ const current = {};
564
+ for (const s of await this._store.getSubscriptions(ct))
565
+ current[s.key] = s;
566
+ const plannable = usable.filter((s) => !failedKeys[s.key] && current[s.key] !== undefined).map((s) => current[s.key]);
567
+ // A stage is kept PER GENERATION (ADR-0060); what makes one resumable is that the epoch, permissions, columns and
568
+ // the cursors it was planned from are all still exactly what they were.
569
+ const generation = stageGeneration(hello, plannable, states, negotiated);
570
+ const pruned = await this._store.pruneStagedPulls(Date.now() - this._options.stagedPullMaxAgeMs, this._options.stagedPullsMaxBytes, generation, ct);
571
+ if (pruned > 0)
572
+ this._log('information', 20021, `Removed ${pruned} staged pull(s) past their age or the byte cap`);
573
+ const staged = await this._store.getStagedPullForGeneration(generation, ct);
574
+ let plan;
575
+ let runId;
576
+ if (staged && staged.createdUtcMs > Date.now() - this._options.stagedPullMaxAgeMs) {
577
+ plan = staged.plan;
578
+ runId = staged.runId;
579
+ this._log('information', 20012, `Resuming staged pull ${runId} (${plan.totalRows} rows planned)`);
580
+ }
581
+ else {
582
+ const backfills = await this._store.getPendingBackfills(ct);
583
+ plan = await this._withRetry((token) => this._transport.planPull({
584
+ clientId: this._clientId,
585
+ subscriptions: plannable.map(toSubscriptionRef),
586
+ tables: negotiated.map((t) => ({ tableId: t.tableId, columns: t.columns.map((c) => c.name) })),
587
+ backfills: backfills.slice(),
588
+ frameVersion: this._options.maxFrameVersion,
589
+ }, token), ct);
590
+ if (plan.error)
591
+ throw syncErrorToException(plan.error);
592
+ runId = guidToN(newGuid());
593
+ const plannedFromCursors = {};
594
+ for (const s of plannable)
595
+ plannedFromCursors[s.key] = s.cursor;
596
+ await this._store.beginStagedPull({ runId, plan, createdUtcMs: Date.now(), backfills, generation, plannedFromCursors, stageSequence: 0 }, ct);
597
+ }
598
+ for (const skipped of plan.skipped) {
599
+ problems.push(skipped);
600
+ if (skipped.action === G9ESubscriptionAction.Reconcile || skipped.action === G9ESubscriptionAction.ResyncRequired) {
601
+ await this._store.updateSubscription(skipped.key, { state: G9ESubscriptionState.NeedsReconcile }, ct);
602
+ }
603
+ }
604
+ for (const c of plan.cursors)
605
+ synced.push(c.key);
606
+ tracker.expect(G9ESyncPhase.Downloading, Math.max(200, (plan.totalRows / 1000) * historyOf(history, STAT_DOWNLOAD_PER_K_ROW, 350)));
607
+ tracker.expect(G9ESyncPhase.Applying, Math.max(100, (plan.totalRows / 1000) * historyOf(history, STAT_APPLY_PER_K_ROW, 250)));
608
+ // ------------------------------------------------------------ 6. download (resumable)
609
+ tracker.enter(G9ESyncPhase.Downloading, plan.totalRows, plan.estimatedBytes);
610
+ const totals = await this._download(runId, plan, tracker, ct);
611
+ downloaded = totals.rows;
612
+ downloadedBytes = totals.bytes;
613
+ // ------------------------------------------------------------ 7. apply (atomic, or in committed slices)
614
+ tracker.enter(G9ESyncPhase.Applying, downloaded);
615
+ const applied = await this._store.applyStagedPull(runId, (rows) => tracker.report(rows), (slice) => {
616
+ if (slice.report.total > 0)
617
+ this._raiseRowsApplied(slice.report, G9ESyncPhase.Applying, slice);
618
+ }, ct);
619
+ changes.merge(applied);
620
+ }
621
+ // ---------------------------------------------------------------- 8. post steps
622
+ for (const step of request.postSteps) {
623
+ tracker.enter(G9ESyncPhase.PostStep, 0, 0, step.name);
624
+ await step.action({ run, changes, progress: (fraction) => tracker.reportFraction(fraction) }, ct);
625
+ }
626
+ // Rows retained after revocation by a RetainWhile rule are re-checked once per run (ADR-0026).
627
+ changes.revokedDeleted += await this._store.reevaluateRetained(ct);
628
+ // The pull has been applied and its cursors committed; now the push failure decides the run's fate.
629
+ if (pushFailure)
630
+ throw pushFailure.error;
631
+ outcome = rejected > 0 || problems.length > 0 ? G9ESyncOutcome.CompletedWithRejections : G9ESyncOutcome.Succeeded;
632
+ tracker.complete(G9ESyncPhase.Completed);
633
+ }
634
+ catch (exception) {
635
+ if (ct.aborted) {
636
+ outcome = G9ESyncOutcome.Canceled;
637
+ error = new G9SyncException(G9SyncErrorCodes.Canceled, run.preempted ? 'Pre-empted by a higher-priority sync.' : 'The sync was canceled.', { cause: exception });
638
+ tracker.complete(G9ESyncPhase.Canceled);
639
+ }
640
+ else {
641
+ error = normalizeError(exception);
642
+ outcome = G9ESyncOutcome.Failed;
643
+ tracker.complete(G9ESyncPhase.Failed);
644
+ this._log('warning', 20011, `Run ${run.runId} failed with ${error.code}`, error);
645
+ }
646
+ }
647
+ finally {
648
+ cancelRegistration();
649
+ linked.dispose();
650
+ if (hostScope) {
651
+ try {
652
+ await hostScope.dispose();
653
+ }
654
+ catch (exception) {
655
+ this._log('warning', 20004, 'A host scope handler threw', exception);
656
+ }
657
+ }
658
+ }
659
+ const durations = tracker.durations;
660
+ const phaseMs = {};
661
+ for (const key of Object.keys(durations))
662
+ phaseMs[key] = Math.floor(durations[key]);
663
+ const result = makeRunResult({
664
+ runId: run.runId,
665
+ outcome,
666
+ error,
667
+ pushed,
668
+ rejected,
669
+ conflicts,
670
+ downloaded,
671
+ applied: changes.total,
672
+ durationMs: Date.now() - startedUtcMs,
673
+ subscriptions: synced,
674
+ problems,
675
+ modelProblems,
676
+ changes,
677
+ phaseMs,
678
+ });
679
+ try {
680
+ await this._persistRun(run, result, durations, startedUtcMs, downloadedBytes, reconciled);
681
+ }
682
+ catch (exception) {
683
+ this._log('warning', 20004, 'A run report handler threw', exception);
684
+ }
685
+ this._log('information', 20010, `Run ${run.runId} finished ${outcomeName(outcome)}: pushed ${pushed}, downloaded ${downloaded}, applied ${changes.total} in ${result.durationMs} ms`);
686
+ run.complete(result);
687
+ for (const listener of this._runCompletedListeners.slice()) {
688
+ try {
689
+ listener(result);
690
+ }
691
+ catch (exception) {
692
+ this._log('warning', 20004, 'A RunCompleted handler threw', exception);
693
+ }
694
+ }
695
+ }
696
+ finally {
697
+ tracker.dispose();
698
+ }
699
+ }
700
+ _planPhases(tracker, history, request, pendingRows) {
701
+ const connecting = historyOf(history, STAT_CONNECTING, 800);
702
+ const pushing = request.push && pendingRows > 0 ? Math.max(300, (pendingRows / 1000) * historyOf(history, STAT_PUSH_PER_K_ROW, 1200)) : 0;
703
+ const planning = request.pull ? historyOf(history, STAT_PLANNING, 700) : 0;
704
+ const downloading = request.pull ? 2000 : 0;
705
+ const applying = request.pull ? 1500 : 0;
706
+ tracker.plan(G9ESyncPhase.Connecting, connecting);
707
+ if (pushing > 0)
708
+ tracker.plan(G9ESyncPhase.Pushing, pushing);
709
+ if (request.pull) {
710
+ tracker.plan(G9ESyncPhase.Reconciling, 0);
711
+ tracker.plan(G9ESyncPhase.Planning, planning);
712
+ tracker.plan(G9ESyncPhase.Downloading, downloading);
713
+ tracker.plan(G9ESyncPhase.Applying, applying);
714
+ }
715
+ const core = connecting + pushing + planning + downloading + applying;
716
+ for (const step of request.postSteps) {
717
+ const hint = step.weightHint;
718
+ tracker.plan(G9ESyncPhase.PostStep, historyOf(history, 'PostStep:' + step.name, hint >= 0.9 ? core * 9 : (core * hint) / (1 - hint)), step.name);
719
+ }
720
+ }
721
+ async _selectSubscriptions(request, all, definitions, problems, ct) {
722
+ const byKey = {};
723
+ for (const s of all)
724
+ byKey[s.key] = s;
725
+ const wanted = request.subscriptions;
726
+ let roots = wanted === null ? all.slice() : all.filter((s) => wanted.indexOf(s.key) >= 0 || wanted.indexOf(s.name) >= 0);
727
+ if (request.priority === G9ESyncPriority.Background && wanted === null) {
728
+ roots = roots.filter((s) => !(definitions[s.name] && definitions[s.name].heavy));
729
+ }
730
+ const ordered = [];
731
+ const visiting = {};
732
+ const done = {};
733
+ const visit = async (record) => {
734
+ if (done[record.key] || visiting[record.key])
735
+ return;
736
+ visiting[record.key] = true;
737
+ const definition = definitions[record.name];
738
+ if (definition) {
739
+ for (const dependency of definition.dependsOn) {
740
+ const parentDefinition = definitions[dependency.name];
741
+ if (!parentDefinition) {
742
+ if (dependency.required) {
743
+ problems.push({ key: record.key, action: G9ESubscriptionAction.Unknown, message: `Required dependency '${dependency.name}' is not available.` });
744
+ }
745
+ continue;
746
+ }
747
+ let parent = byKey[dependency.name];
748
+ if (!parent) {
749
+ parent = await this._store.ensureSubscription(dependency.name, new G9SyncParameters(), parentDefinition.ephemeral, parentDefinition.ephemeral ? parentDefinition.ttlSeconds * 1000 : null, false, ct);
750
+ byKey[parent.key] = parent;
751
+ }
752
+ await visit(parent);
753
+ }
754
+ }
755
+ done[record.key] = true;
756
+ ordered.push(record);
757
+ };
758
+ // Creation order. Two subscriptions created in the same millisecond keep it through their slots (handed out lowest
759
+ // free first), then the key, ordinal (JS `<` compares UTF-16 units, like StringComparer.Ordinal) — never the order
760
+ // the store happened to list them in (by key: a random GUID decided which site's rows came first, LES-0052).
761
+ const sorted = roots
762
+ .map((r, i) => ({ r, i }))
763
+ .sort((a, b) => a.r.createdUtcMs - b.r.createdUtcMs || a.r.slot - b.r.slot || (a.r.key < b.r.key ? -1 : a.r.key > b.r.key ? 1 : 0) || a.i - b.i);
764
+ for (const { r } of sorted)
765
+ await visit(r);
766
+ return ordered;
767
+ }
768
+ async _pushAll(negotiated, subscriptions, serverMaxRows, serverMaxBytes, tracker, changes, ct) {
769
+ let pushed = 0;
770
+ let rejected = 0;
771
+ let conflicts = 0;
772
+ const references = subscriptions.map(toSubscriptionRef);
773
+ // The server's limits win over the device's preference; bytes keep a margin for the header and compression variance.
774
+ const targetRows = Math.max(1, serverMaxRows > 0 ? Math.min(this._options.pushBatchRows, serverMaxRows) : this._options.pushBatchRows);
775
+ let batchRows = targetRows;
776
+ const batchBytes = serverMaxBytes > 0 ? Math.floor(serverMaxBytes * 0.9) : Number.MAX_SAFE_INTEGER;
777
+ for (;;) {
778
+ const batch = await this._store.buildPushBatch(negotiated, batchRows, batchBytes, ct);
779
+ if (!batch)
780
+ break;
781
+ rejected += batch.rejectedLocally;
782
+ if (batch.rowCount === 0 && batch.commands.length === 0)
783
+ break; // only rows that can never be uploaded were left
784
+ const request = {
785
+ clientId: this._clientId,
786
+ batchId: batch.batchId,
787
+ subscriptions: references,
788
+ tables: batch.tables.slice(),
789
+ commands: batch.commands.slice(),
790
+ // Canonical rows come back in the columns the DEVICE holds (LES-0035).
791
+ heldTables: negotiated
792
+ .filter((t) => batch.commands.length > 0 || batch.tables.some((p) => p.tableId === t.tableId))
793
+ .map((t) => ({ tableId: t.tableId, columns: t.columns.map((c) => c.name) })),
794
+ frameVersion: this._options.maxFrameVersion,
795
+ };
796
+ let result;
797
+ try {
798
+ result = await this._withRetry((token) => this._transport.push(request, toChunks(batch), token), ct);
799
+ if (result.error)
800
+ throw syncErrorToException(result.error);
801
+ }
802
+ catch (exception) {
803
+ if (isSyncException(exception) && exception.code === G9SyncErrorCodes.RejectTooMany && batchRows > 1 && batch.rowCount > 1) {
804
+ // The server rolled the WHOLE push back for too many rejections (ADR-0063): halve and resend the smaller prefix
805
+ // under the same batch id, so the valid rows get through and the bad ones are parked one by one.
806
+ batchRows = Math.max(1, Math.floor(Math.min(batchRows, batch.rowCount) / 2));
807
+ this._log('warning', 20023, `Push batch ${batch.batchId} of ${batch.rowCount} rows was rolled back for too many rejections; sending it again as ${batchRows} rows so the valid rows get through`);
808
+ continue;
809
+ }
810
+ throw exception;
811
+ }
812
+ const report = await this._store.applyPushResult(batch, result, ct);
813
+ changes.merge(report);
814
+ if (report.upserts + report.deletes > 0)
815
+ this._raiseRowsApplied(report, G9ESyncPhase.Pushing);
816
+ pushed += batch.rowCount + batch.commands.length;
817
+ rejected += result.rejections.length;
818
+ conflicts += result.conflicts.length;
819
+ tracker.report(pushed);
820
+ this._log('debug', 20014, `Pushed batch ${batch.batchId}: ${batch.rowCount} rows, ${result.rejections.length} rejected, ${result.conflicts.length} conflicts, replayed ${result.alreadyApplied}`);
821
+ if (report.accepted + report.rejected + report.conflicts + report.commandsCompleted === 0 && !result.alreadyApplied) {
822
+ this._log('warning', 20015, `Push batch ${batch.batchId} changed no row state; stopping the push loop for this run`);
823
+ break;
824
+ }
825
+ if (batch.rowCount < batchRows && batch.commands.length < batchRows && !batch.truncated && !result.alreadyApplied)
826
+ break;
827
+ // After a split: grow back towards the normal size.
828
+ if (batchRows < targetRows)
829
+ batchRows = Math.min(targetRows, batchRows * 2);
830
+ }
831
+ return { pushed, rejected, conflicts };
832
+ }
833
+ async _download(runId, plan, tracker, ct) {
834
+ var _a;
835
+ for (let attempt = 0;; attempt++) {
836
+ const resume = await this._store.getResumePoint(runId, ct);
837
+ let rows = resume.stagedRows;
838
+ let bytes = resume.stagedBytes;
839
+ if (resume.complete)
840
+ return { rows, bytes };
841
+ tracker.report(rows, bytes);
842
+ try {
843
+ for await (const chunk of this._transport.pull(plan.token, resume.continuation, ct)) {
844
+ await this._store.stageChunk(runId, chunk, ct);
845
+ if (chunk.isFinal)
846
+ return { rows, bytes };
847
+ rows += chunk.rowCount;
848
+ bytes += chunk.payload ? chunk.payload.length : 0;
849
+ tracker.report(rows, bytes, null, (_a = chunk.subscriptionKey) !== null && _a !== void 0 ? _a : null);
850
+ }
851
+ throw new G9SyncException(G9SyncErrorCodes.NetworkFailure, 'The pull stream ended before its final chunk.');
852
+ }
853
+ catch (exception) {
854
+ if (isSyncException(exception) &&
855
+ (exception.code === G9SyncErrorCodes.TokenExpired ||
856
+ exception.code === G9SyncErrorCodes.TokenInvalid ||
857
+ exception.code === G9SyncErrorCodes.ResyncEpoch ||
858
+ exception.code === G9SyncErrorCodes.ResyncAuthChanged)) {
859
+ await this._store.discardStagedPull(runId);
860
+ throw exception;
861
+ }
862
+ if (!ct.aborted && isTransientError(exception) && attempt < this._options.maxRetries) {
863
+ this._log('warning', 20013, `Transient pull failure, retry ${attempt + 1}: ${messageOf(exception)}`);
864
+ await delay(this._backoff(attempt), ct);
865
+ await this._ensureConnected(ct);
866
+ continue;
867
+ }
868
+ throw exception;
869
+ }
870
+ }
871
+ }
872
+ async _withRetry(operation, ct) {
873
+ for (let attempt = 0;; attempt++) {
874
+ try {
875
+ await this._ensureConnected(ct);
876
+ return await operation(ct);
877
+ }
878
+ catch (exception) {
879
+ if (!ct.aborted && isTransientError(exception) && attempt < this._options.maxRetries) {
880
+ this._log('warning', 20013, `Transient call failure, retry ${attempt + 1}: ${messageOf(exception)}`);
881
+ await delay(this._backoff(attempt), ct);
882
+ continue;
883
+ }
884
+ throw exception;
885
+ }
886
+ }
887
+ }
888
+ _backoff(attempt) {
889
+ return Math.min(30000, this._options.retryBaseDelayMs * Math.pow(2, attempt)) * (0.85 + Math.random() * 0.3);
890
+ }
891
+ async _ensureConnected(ct) {
892
+ if (!this._transport.isConnected)
893
+ await this._transport.connect(ct);
894
+ }
895
+ async _persistRun(run, result, durations, startedUtcMs, downloadedBytes, reconciledRows) {
896
+ const diagnostics = result.changes.diagnostics;
897
+ const phaseMs = {};
898
+ for (const key of Object.keys(durations))
899
+ phaseMs[key] = Math.floor(durations[key]);
900
+ const report = {
901
+ runId: run.runId,
902
+ startedUtcMs,
903
+ durationMs: result.durationMs,
904
+ outcome: outcomeName(result.outcome),
905
+ priority: priorityName(run.request.priority),
906
+ errorCode: result.error ? result.error.code : null,
907
+ errorMessage: result.error ? result.error.message : null,
908
+ pushed: result.pushed,
909
+ rejected: result.rejected,
910
+ conflicts: result.conflicts,
911
+ downloaded: result.downloaded,
912
+ applied: result.applied,
913
+ downloadedBytes,
914
+ subscriptions: result.subscriptions.slice(),
915
+ phaseMs,
916
+ triggersSkipped: diagnostics.triggersSkipped,
917
+ foreignTriggers: diagnostics.foreignTriggers.slice(),
918
+ applyTransactions: diagnostics.transactions,
919
+ indexesDeferred: diagnostics.deferredIndexCount,
920
+ indexRebuildMs: diagnostics.indexRebuildMs,
921
+ liveIndexReasons: { ...diagnostics.liveIndexReasons },
922
+ unchangedRows: result.changes.unchangedRows,
923
+ };
924
+ await this._store.saveRunReport(report, this._options.keepRunReports);
925
+ if (!result.isSuccess)
926
+ return;
927
+ const samples = {};
928
+ const connecting = durations[phaseName(G9ESyncPhase.Connecting)];
929
+ if (connecting !== undefined)
930
+ samples[STAT_CONNECTING] = connecting;
931
+ const planning = durations[phaseName(G9ESyncPhase.Planning)];
932
+ if (planning !== undefined)
933
+ samples[STAT_PLANNING] = planning;
934
+ const pushingMs = durations[phaseName(G9ESyncPhase.Pushing)];
935
+ if (result.pushed >= 100 && pushingMs !== undefined)
936
+ samples[STAT_PUSH_PER_K_ROW] = pushingMs / (result.pushed / 1000);
937
+ const downloadingMs = durations[phaseName(G9ESyncPhase.Downloading)];
938
+ if (result.downloaded >= 500 && downloadingMs !== undefined)
939
+ samples[STAT_DOWNLOAD_PER_K_ROW] = downloadingMs / (result.downloaded / 1000);
940
+ const applyingMs = durations[phaseName(G9ESyncPhase.Applying)];
941
+ if (result.downloaded >= 500 && applyingMs !== undefined)
942
+ samples[STAT_APPLY_PER_K_ROW] = applyingMs / (result.downloaded / 1000);
943
+ const reconcilingMs = durations[phaseName(G9ESyncPhase.Reconciling)];
944
+ if (reconcilingMs !== undefined && reconciledRows >= 0 && run.request.pull) {
945
+ samples[STAT_RECONCILE_PER_SUBSCRIPTION] = reconcilingMs / Math.max(1, result.subscriptions.length);
946
+ }
947
+ for (const step of run.request.postSteps) {
948
+ const stepMs = durations['PostStep:' + step.name];
949
+ if (stepMs !== undefined)
950
+ samples['PostStep:' + step.name] = stepMs;
951
+ }
952
+ if (Object.keys(samples).length > 0)
953
+ await this._store.recordPhaseStatistics(samples);
954
+ if (result.applied > 0)
955
+ await this._store.purgeDeletedFeed(this._options.keepDeletedFeedEntries);
956
+ }
957
+ // ================================================================ reconciliation (D-07)
958
+ async _reconcileSubscription(subscription, definition, negotiated, ct) {
959
+ var _a, _b;
960
+ const tables = [];
961
+ for (const name of definition.tables) {
962
+ const table = negotiated.find((t) => t.name.toLowerCase() === name.toLowerCase());
963
+ // UploadOnly: the device's own rows, nothing on the server to compare them with.
964
+ if (table && table.direction !== G9ESyncDirection.UploadOnly)
965
+ tables.push(table);
966
+ }
967
+ const request = { clientId: this._clientId, subscription: toSubscriptionRef(subscription), tables: [], frameVersion: this._options.maxFrameVersion };
968
+ for (const table of tables)
969
+ request.tables.push(await this._store.computeDigest(subscription.key, table, ct));
970
+ const serverRows = {};
971
+ let summary = null;
972
+ for await (const chunk of this._transport.reconcile(request, ct)) {
973
+ if (chunk.isFinal) {
974
+ summary = (_a = chunk.reconcileSummary) !== null && _a !== void 0 ? _a : null;
975
+ break;
976
+ }
977
+ if (!chunk.payload)
978
+ continue;
979
+ const reader = await openRowBatch(chunk.payload, { maxBodyBytes: this._options.maxDecodedFrameBytes });
980
+ while (reader.nextSection()) {
981
+ let rows = serverRows[reader.tableId];
982
+ if (!rows)
983
+ serverRows[reader.tableId] = rows = new Map();
984
+ while (reader.readRow())
985
+ rows.set(reader.key, { version: reader.version, hash: reader.rowHash });
986
+ }
987
+ }
988
+ if (!summary)
989
+ throw new G9SyncException(G9SyncErrorCodes.NetworkFailure, 'The reconcile stream ended before its summary.');
990
+ const report = new G9ApplyReport();
991
+ let handedToSnapshot = false;
992
+ for (const mismatch of summary.mismatched) {
993
+ const table = tables.find((t) => t.tableId === mismatch.tableId);
994
+ if (!table)
995
+ continue;
996
+ const server = (_b = serverRows[table.tableId]) !== null && _b !== void 0 ? _b : new Map();
997
+ const buckets = new Set(mismatch.buckets);
998
+ const serverKeys = new Set();
999
+ server.forEach((_value, key) => serverKeys.add(key));
1000
+ const local = await this._store.readLocalEntries(subscription.key, table, buckets, serverKeys, ct);
1001
+ const localByKey = new Map();
1002
+ for (const entry of local)
1003
+ localByKey.set(entry.key, entry);
1004
+ const fetch = [];
1005
+ const addMembership = [];
1006
+ const moveOut = [];
1007
+ server.forEach(({ hash }, key) => {
1008
+ const entry = localByKey.get(key);
1009
+ if (!entry)
1010
+ fetch.push(key);
1011
+ else if (entry.dirty) {
1012
+ // A pending local change is never overwritten here; a row visible again still gets its membership back.
1013
+ if (!entry.member)
1014
+ addMembership.push(key);
1015
+ }
1016
+ else if (!entry.hash.equals(hash))
1017
+ fetch.push(key);
1018
+ else if (!entry.member)
1019
+ addMembership.push(key);
1020
+ });
1021
+ for (const entry of local) {
1022
+ // Rows with pending changes are released too; the store's revocation policy decides (ADR-0026).
1023
+ if (entry.member && buckets.has(entry.bucket) && !server.has(entry.key))
1024
+ moveOut.push(entry.key);
1025
+ }
1026
+ // A difference this large is a download, not a repair (ADR-0066).
1027
+ if (this._options.reconcileSnapshotThreshold > 0 && fetch.length >= this._options.reconcileSnapshotThreshold) {
1028
+ const released = await this._store.applyReconcile(subscription.key, table, [], addMembership, moveOut, definition.moveOutPolicy, ct);
1029
+ report.merge(released);
1030
+ handedToSnapshot = true;
1031
+ this._log('information', 20024, `Reconciling ${subscription.key} / ${table.name} would fetch ${fetch.length} rows (threshold ${this._options.reconcileSnapshotThreshold}): released what left, and leaving the rows to a staged snapshot pull`);
1032
+ continue;
1033
+ }
1034
+ // Fetched and applied in groups (ADR-0066).
1035
+ const groupKeys = Math.max(Math.max(1, this._options.fetchKeysPerRequest), this._options.reconcileApplyRows);
1036
+ let pendingMembership = addMembership;
1037
+ let pendingMoveOut = moveOut;
1038
+ let fetched = 0;
1039
+ for (const group of chunked(fetch, groupKeys)) {
1040
+ const frames = [];
1041
+ const returned = new Set();
1042
+ for (const keys of chunked(group, Math.max(1, this._options.fetchKeysPerRequest))) {
1043
+ const fetchRequest = {
1044
+ clientId: this._clientId,
1045
+ subscription: toSubscriptionRef(subscription),
1046
+ tableId: table.tableId,
1047
+ keys,
1048
+ columns: table.columns.map((c) => c.name),
1049
+ frameVersion: this._options.maxFrameVersion,
1050
+ };
1051
+ for await (const chunk of this._transport.fetchRows(fetchRequest, ct)) {
1052
+ if (chunk.isFinal || !chunk.payload)
1053
+ continue;
1054
+ frames.push(chunk.payload);
1055
+ const reader = await openRowBatch(chunk.payload, { maxBodyBytes: this._options.maxDecodedFrameBytes });
1056
+ while (reader.nextSection()) {
1057
+ while (reader.readRow())
1058
+ returned.add(reader.key);
1059
+ }
1060
+ }
1061
+ }
1062
+ // A key listed but not returned left the subscription between listing and fetch.
1063
+ const groupMoveOut = group.filter((k) => { var _a, _b; return !returned.has(k) && ((_b = (_a = localByKey.get(k)) === null || _a === void 0 ? void 0 : _a.member) !== null && _b !== void 0 ? _b : false); });
1064
+ for (const key of pendingMoveOut)
1065
+ groupMoveOut.push(key);
1066
+ const applied = await this._store.applyReconcile(subscription.key, table, frames, pendingMembership, groupMoveOut, definition.moveOutPolicy, ct);
1067
+ report.merge(applied);
1068
+ pendingMembership = [];
1069
+ pendingMoveOut = [];
1070
+ fetched += group.length;
1071
+ }
1072
+ if (fetched === 0 && (pendingMembership.length > 0 || pendingMoveOut.length > 0)) {
1073
+ report.merge(await this._store.applyReconcile(subscription.key, table, [], pendingMembership, pendingMoveOut, definition.moveOutPolicy, ct));
1074
+ }
1075
+ this._log('information', 20020, `Reconciled ${subscription.key} / ${table.name}: ${mismatch.buckets.length} buckets differed, fetched ${fetch.length}, adopted ${addMembership.length}, moved out ${moveOut.length}`);
1076
+ }
1077
+ await this._store.updateSubscription(subscription.key, handedToSnapshot
1078
+ ? // No cursor: the plan step of this run sees an initial pull and stages a snapshot.
1079
+ { clearCursor: true, state: G9ESubscriptionState.Active, clearError: true, reconciled: true }
1080
+ : {
1081
+ cursor: summary.cursor,
1082
+ definitionHash: summary.definitionHash,
1083
+ authHash: summary.authHash,
1084
+ state: G9ESubscriptionState.Active,
1085
+ clearError: true,
1086
+ reconciled: true,
1087
+ }, ct);
1088
+ return report;
1089
+ }
1090
+ }
1091
+ // ---------------------------------------------------------------- helpers
1092
+ function remove(list, item) {
1093
+ const index = list.indexOf(item);
1094
+ if (index >= 0)
1095
+ list.splice(index, 1);
1096
+ }
1097
+ function messageOf(error) {
1098
+ return error instanceof Error ? error.message : String(error);
1099
+ }
1100
+ function historyOf(history, key, fallback) {
1101
+ const value = history[key];
1102
+ return value !== undefined && value > 0 ? value : fallback;
1103
+ }
1104
+ function needsReconcile(subscription, request, states, definitions, nowMs) {
1105
+ var _a, _b;
1106
+ if (subscription.state === G9ESubscriptionState.NeedsReconcile)
1107
+ return true;
1108
+ const state = states[subscription.key];
1109
+ if (state && (state.action === G9ESubscriptionAction.Reconcile || state.action === G9ESubscriptionAction.ResyncRequired))
1110
+ return true;
1111
+ if (subscription.cursor === null)
1112
+ return false;
1113
+ if (request.forceReconcile)
1114
+ return true;
1115
+ const definition = definitions[subscription.name];
1116
+ return (definition !== undefined &&
1117
+ definition.reconcileIntervalMinutes > 0 &&
1118
+ ((_b = (_a = subscription.lastReconcileUtcMs) !== null && _a !== void 0 ? _a : subscription.lastSuccessUtcMs) !== null && _b !== void 0 ? _b : subscription.createdUtcMs) < nowMs - definition.reconcileIntervalMinutes * 60000);
1119
+ }
1120
+ /**
1121
+ * Everything a staged pull's rows depend on (ADR-0046): the server's epoch, the exact set of subscriptions with their
1122
+ * definition and authorisation hashes, and the negotiated columns. The cursor is deliberately NOT part of it (ADR-0060).
1123
+ */
1124
+ export function stageGeneration(hello, plannable, states, negotiated) {
1125
+ var _a, _b, _c, _d;
1126
+ let text = guidToN(hello.epoch) + '\n';
1127
+ for (const subscription of plannable.slice().sort((a, b) => compareOrdinal(a.key, b.key))) {
1128
+ const state = states[subscription.key];
1129
+ text += subscription.key + '\u001F' + ((_b = (_a = (state ? state.definitionHash : null)) !== null && _a !== void 0 ? _a : subscription.definitionHash) !== null && _b !== void 0 ? _b : '') + '\u001F' + ((_d = (_c = (state ? state.authHash : null)) !== null && _c !== void 0 ? _c : subscription.authHash) !== null && _d !== void 0 ? _d : '') + '\n';
1130
+ }
1131
+ text += '\n';
1132
+ const tables = negotiated.map((t, i) => ({ t, i })).sort((a, b) => a.t.tableId - b.t.tableId || a.i - b.i);
1133
+ for (const { t } of tables)
1134
+ text += String(t.tableId) + '\u001F' + t.columns.map((c) => c.name).join(',') + '\n';
1135
+ return toHex(sha256(utf8Encode(text)));
1136
+ }
1137
+ function actionCode(action) {
1138
+ switch (action) {
1139
+ case G9ESubscriptionAction.Unknown:
1140
+ return G9SyncErrorCodes.UnknownSubscription;
1141
+ case G9ESubscriptionAction.Forbidden:
1142
+ return G9SyncErrorCodes.SubscriptionForbidden;
1143
+ default:
1144
+ return G9SyncErrorCodes.InvalidParameters;
1145
+ }
1146
+ }
1147
+ function chunked(items, size) {
1148
+ const out = [];
1149
+ for (let i = 0; i < items.length; i += size)
1150
+ out.push(items.slice(i, i + size));
1151
+ return out;
1152
+ }
1153
+ async function* toChunks(batch) {
1154
+ let sequence = 0;
1155
+ for (const frame of batch.frames)
1156
+ yield { sequence: sequence++, payload: frame, rowCount: 0, isFinal: false };
1157
+ }
1158
+ //# sourceMappingURL=engine.js.map