velocious 1.0.700 → 1.0.702

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 (56) hide show
  1. package/README.md +1 -0
  2. package/build/configuration-types.js +73 -3
  3. package/build/configuration.js +113 -20
  4. package/build/frontend-model-resource/base-resource.js +150 -2
  5. package/build/frontend-models/resource-definition.js +47 -0
  6. package/build/src/configuration-types.d.ts +168 -7
  7. package/build/src/configuration-types.d.ts.map +1 -1
  8. package/build/src/configuration-types.js +68 -4
  9. package/build/src/configuration.d.ts +22 -2
  10. package/build/src/configuration.d.ts.map +1 -1
  11. package/build/src/configuration.js +110 -20
  12. package/build/src/frontend-model-resource/base-resource.d.ts +76 -3
  13. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  14. package/build/src/frontend-model-resource/base-resource.js +127 -3
  15. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  16. package/build/src/frontend-models/resource-definition.js +43 -1
  17. package/build/src/sync/local-mutation-log.d.ts +6 -3
  18. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  19. package/build/src/sync/local-mutation-log.js +11 -4
  20. package/build/src/sync/peer-mutation-bundle.d.ts +26 -0
  21. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -1
  22. package/build/src/sync/peer-mutation-bundle.js +7 -1
  23. package/build/src/sync/sync-api-client.d.ts +3 -1
  24. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  25. package/build/src/sync/sync-api-client.js +46 -8
  26. package/build/src/sync/sync-client-types.d.ts +24 -12
  27. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  28. package/build/src/sync/sync-client-types.js +7 -5
  29. package/build/src/sync/sync-client.d.ts +101 -0
  30. package/build/src/sync/sync-client.d.ts.map +1 -1
  31. package/build/src/sync/sync-client.js +236 -24
  32. package/build/src/sync/sync-offline-session.d.ts +328 -0
  33. package/build/src/sync/sync-offline-session.d.ts.map +1 -0
  34. package/build/src/sync/sync-offline-session.js +617 -0
  35. package/build/src/sync/sync-resource-base.d.ts +26 -28
  36. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  37. package/build/src/sync/sync-resource-base.js +11 -45
  38. package/build/sync/local-mutation-log.js +10 -3
  39. package/build/sync/peer-mutation-bundle.js +6 -0
  40. package/build/sync/sync-api-client.js +53 -7
  41. package/build/sync/sync-client-types.js +6 -4
  42. package/build/sync/sync-client.js +264 -21
  43. package/build/sync/sync-offline-session.js +713 -0
  44. package/build/sync/sync-resource-base.js +10 -53
  45. package/package.json +1 -1
  46. package/src/configuration-types.js +73 -3
  47. package/src/configuration.js +113 -20
  48. package/src/frontend-model-resource/base-resource.js +150 -2
  49. package/src/frontend-models/resource-definition.js +47 -0
  50. package/src/sync/local-mutation-log.js +10 -3
  51. package/src/sync/peer-mutation-bundle.js +6 -0
  52. package/src/sync/sync-api-client.js +53 -7
  53. package/src/sync/sync-client-types.js +6 -4
  54. package/src/sync/sync-client.js +264 -21
  55. package/src/sync/sync-offline-session.js +713 -0
  56. package/src/sync/sync-resource-base.js +10 -53
@@ -0,0 +1,713 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ createSignedMutation,
5
+ generateSyncSigningKeyPair,
6
+ mutationIdempotencyKey,
7
+ verifyDeviceCertificate
8
+ } from "./device-identity.js"
9
+ import {exportPeerMutationBundle, importPeerMutationBundle} from "./peer-mutation-bundle.js"
10
+ import {replayResultLocalStatus} from "./conflict-strategy.js"
11
+
12
+ /**
13
+ * Stored device signing identity: the backend-signed certificate plus the
14
+ * device private key matching the certificate's public key.
15
+ * @typedef {object} SyncOfflineDeviceIdentity
16
+ * @property {import("./device-identity.js").DeviceCertificate} certificate - Backend-signed device certificate.
17
+ * @property {import("./device-identity.js").SyncJsonWebKey} privateKey - Device private signing key (JWK).
18
+ */
19
+ /**
20
+ * Result of one offline session replay pass over the durable mutation logs.
21
+ * @typedef {object} SyncOfflineReplayResult
22
+ * @property {number} conflictCount - Records left in `conflict`.
23
+ * @property {number} pendingCount - Records still pending (blocked dependencies or unacknowledged).
24
+ * @property {number} rejectedCount - Records the backend rejected.
25
+ * @property {Array<{clientMutationId: string, idempotencyKey: string, mutation: import("./device-identity.js").SyncMutation, recordId: string, serverResult: Record<string, ReturnType<typeof JSON.parse>> | null, status: import("./local-mutation-log.js").LocalMutationStatus}>} results - Per-mutation mapped results in replay order.
26
+ * @property {number} syncedCount - Records acknowledged by the backend.
27
+ */
28
+ /**
29
+ * Session summary returned by start/refresh.
30
+ * @typedef {object} SyncOfflineSessionSummary
31
+ * @property {string} actorDeviceId - Certificate device identity.
32
+ * @property {string} actorUserId - Certificate user identity.
33
+ * @property {string} expiresAt - Live grant expiry.
34
+ * @property {string} grantId - Live grant id.
35
+ */
36
+
37
+ /**
38
+ * Framework-owned long-offline session behind SyncClient: device identity
39
+ * lifecycle, backend-issued offline grants, mutation signing, signed replay,
40
+ * and peer bundle transfer. SyncClient owns the public API; this class owns the
41
+ * session state. The app only provides durable storage adapters, the
42
+ * enrollment hook, and the grant scopes callback.
43
+ */
44
+ export default class SyncOfflineSession {
45
+ /**
46
+ * Builds an offline session from the normalized `sync.client.offline` block.
47
+ * @param {object} args - Session args.
48
+ * @param {() => string | Promise<string>} args.authenticationToken - Resolves the auth token sent with bootstrap/replay requests.
49
+ * @param {import("../configuration-types.js").NormalizedVelociousSyncClientOfflineConfiguration} args.config - Normalized offline configuration.
50
+ * @param {Array<import("./local-mutation-log.js").default>} args.mutationLogs - Durable local mutation logs the session signs, replays, resigns, and exports.
51
+ * @param {(path: string, payload: Record<string, ReturnType<typeof JSON.parse>>, options?: {signal?: AbortSignal}) => Promise<ReturnType<typeof JSON.parse>>} args.post - Framework POSTer over the configured transport.
52
+ */
53
+ constructor({authenticationToken, config, mutationLogs, post}) {
54
+ if (!Array.isArray(mutationLogs) || mutationLogs.length === 0) throw new Error("SyncOfflineSession requires at least one mutation log")
55
+
56
+ this.authenticationToken = authenticationToken
57
+ this.config = config
58
+ this.mutationLogs = mutationLogs
59
+ this.post = post
60
+
61
+ /** @type {SyncOfflineDeviceIdentity | null} */
62
+ this._identity = null
63
+ /** @type {Map<string, import("./offline-grant.js").SignedOfflineGrant>} */
64
+ this._grants = new Map()
65
+ this._grantsLoaded = false
66
+ /** @type {import("./offline-grant.js").SignedOfflineGrant | null} */
67
+ this._activeGrant = null
68
+ /** @type {Record<string, import("../configuration-types.js").NormalizedFrontendModelResourceSyncConfiguration> | null} */
69
+ this._syncManifest = null
70
+ /** @type {Promise<SyncOfflineSessionSummary> | null} */
71
+ this._startPromise = null
72
+ /** @type {Promise<SyncOfflineReplayResult> | null} */
73
+ this._replayPromise = null
74
+ }
75
+
76
+ /**
77
+ * Whether the session currently has a usable device identity and live grant.
78
+ * @returns {boolean} - Whether the session is active.
79
+ */
80
+ isActive() {
81
+ return Boolean(this._identity && this._liveGrant())
82
+ }
83
+
84
+ /**
85
+ * Resolves the device signing identity (generating and enrolling a key pair
86
+ * when the app-owned storage has none) and bootstraps a live offline grant.
87
+ * Idempotent and single-flighted: concurrent starts share one bootstrap.
88
+ * @returns {Promise<SyncOfflineSessionSummary>} - Session summary.
89
+ */
90
+ async start() {
91
+ if (this._identity && this._liveGrant()) return this.sessionSummary()
92
+
93
+ if (!this._startPromise) {
94
+ this._startPromise = this._startSession().then((summary) => {
95
+ this._startPromise = null
96
+
97
+ return summary
98
+ }, (error) => {
99
+ this._startPromise = null
100
+
101
+ throw error
102
+ })
103
+ }
104
+
105
+ return await this._startPromise
106
+ }
107
+
108
+ /**
109
+ * Drops the in-memory session (identity and grants stay in the app-owned
110
+ * durable storage; pending mutations stay in the durable mutation logs).
111
+ * @returns {Promise<void>} - Resolves after the session is dropped.
112
+ */
113
+ async stop() {
114
+ if (this._replayPromise) await this._replayPromise.catch(() => undefined)
115
+
116
+ this._identity = null
117
+ this._activeGrant = null
118
+ this._syncManifest = null
119
+ this._grantsLoaded = false
120
+ }
121
+
122
+ /**
123
+ * Runs one start attempt: identity first, then grant bootstrap.
124
+ * @returns {Promise<SyncOfflineSessionSummary>} - Session summary.
125
+ */
126
+ async _startSession() {
127
+ await this._ensureIdentity()
128
+ await this._loadRetainedGrants()
129
+ await this._ensureLiveGrant()
130
+
131
+ return this.sessionSummary()
132
+ }
133
+
134
+ /**
135
+ * Returns the current session summary.
136
+ * @returns {SyncOfflineSessionSummary} - Session summary.
137
+ */
138
+ sessionSummary() {
139
+ const identity = this._requireIdentity()
140
+ const grant = this._requireLiveGrant()
141
+
142
+ return {
143
+ actorDeviceId: identity.certificate.certificate.actorDeviceId,
144
+ actorUserId: identity.certificate.certificate.actorUserId,
145
+ expiresAt: grant.grant.expiresAt,
146
+ grantId: grant.grant.grantId
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Resolves the device identity: load it from the app-owned storage, or
152
+ * generate a key pair, run the app-owned enrollment hook, verify the returned
153
+ * certificate against the configured backend public key, and store it.
154
+ * @returns {Promise<SyncOfflineDeviceIdentity>} - Device signing identity.
155
+ */
156
+ async _ensureIdentity() {
157
+ if (this._identity) return this._identity
158
+
159
+ const stored = await this.config.deviceIdentityStorage.load()
160
+
161
+ if (stored) {
162
+ this._identity = await this._verifiedIdentity(stored)
163
+
164
+ return this._identity
165
+ }
166
+
167
+ const keyPair = await generateSyncSigningKeyPair()
168
+ const certificate = await this.config.enrollDevice({
169
+ configuration: this.config.configuration,
170
+ publicKey: keyPair.publicKey
171
+ })
172
+ const identity = await this._verifiedIdentity({certificate, privateKey: keyPair.privateKey})
173
+
174
+ await this.config.deviceIdentityStorage.save(/** @type {SyncOfflineDeviceIdentity} */ (cloneJson(identity, "device identity")))
175
+
176
+ this._identity = identity
177
+
178
+ return identity
179
+ }
180
+
181
+ /**
182
+ * Verifies a stored/enrolled certificate against the configured backend
183
+ * public key (when one is configured) and returns the identity.
184
+ * @param {SyncOfflineDeviceIdentity} identity - Candidate identity.
185
+ * @returns {Promise<SyncOfflineDeviceIdentity>} - Verified identity.
186
+ */
187
+ async _verifiedIdentity(identity) {
188
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)) {
189
+ throw new Error("sync.client.offline device identity storage must provide {certificate, privateKey} or null")
190
+ }
191
+ if (!identity.certificate || !identity.privateKey) throw new Error("sync.client.offline device identity requires a certificate and a privateKey")
192
+
193
+ if (this.config.backendPublicKey) {
194
+ await verifyDeviceCertificate({
195
+ backendPublicKey: this.config.backendPublicKey,
196
+ certificate: identity.certificate
197
+ })
198
+ }
199
+
200
+ return identity
201
+ }
202
+
203
+ /**
204
+ * Loads app-retained grants once so a restarted device can still replay
205
+ * mutations that were signed under an older grant (peer-forwarded mutations
206
+ * keep the grant their original signer used).
207
+ * @returns {Promise<void>} - Resolves after retention is loaded.
208
+ */
209
+ async _loadRetainedGrants() {
210
+ if (this._grantsLoaded) return
211
+
212
+ this._grantsLoaded = true
213
+ if (!this.config.grantStorage) return
214
+
215
+ const retained = await this.config.grantStorage.load()
216
+
217
+ if (!Array.isArray(retained)) throw new Error("sync.client.offline grantStorage.load must resolve an array of signed offline grants")
218
+
219
+ for (const signedGrant of retained) {
220
+ if (!signedGrant || typeof signedGrant !== "object" || !signedGrant.grant || typeof signedGrant.grant.grantId !== "string") continue
221
+
222
+ this._grants.set(signedGrant.grant.grantId, signedGrant)
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Returns the active grant when it is still live, otherwise null. Expiry is
228
+ * checked by timestamp only: the grant's HMAC signature is backend-only and
229
+ * can never be verified on the device.
230
+ * @param {Date} [now] - Check time.
231
+ * @returns {import("./offline-grant.js").SignedOfflineGrant | null} - Live grant or null.
232
+ */
233
+ _liveGrant(now = this._now()) {
234
+ if (!this._activeGrant) return null
235
+ if (new Date(this._activeGrant.grant.expiresAt).getTime() <= now.getTime()) return null
236
+
237
+ return this._activeGrant
238
+ }
239
+
240
+ /**
241
+ * Ensures a live grant exists, bootstrapping a new one when the current grant
242
+ * is missing or expired. A refreshed grant re-signs this device's pending
243
+ * records so the backend always sees the grant an intent is replayed under.
244
+ * @returns {Promise<import("./offline-grant.js").SignedOfflineGrant>} - Live grant.
245
+ */
246
+ async _ensureLiveGrant() {
247
+ const live = this._liveGrant()
248
+
249
+ if (live) return live
250
+
251
+ const granted = await this.bootstrapGrant()
252
+
253
+ await this._resignPendingRecords(granted)
254
+
255
+ return granted
256
+ }
257
+
258
+ /**
259
+ * Requests a fresh offline grant from the built-in sync bootstrap endpoint
260
+ * and retains it under its grant id together with the sync manifest whose
261
+ * policy hashes mutations are signed against.
262
+ * @returns {Promise<import("./offline-grant.js").SignedOfflineGrant>} - Newly issued grant.
263
+ */
264
+ async bootstrapGrant() {
265
+ const identity = await this._ensureIdentity()
266
+ const authenticationToken = await this.authenticationToken()
267
+ const scopes = await this.config.grantScopes({configuration: this.config.configuration})
268
+
269
+ if (!scopes || typeof scopes !== "object" || Array.isArray(scopes)) throw new Error("sync.client.offline.grantScopes must resolve a plain object of grant scopes")
270
+
271
+ const response = await this.post(this.config.bootstrapPath, {
272
+ authenticationToken,
273
+ deviceId: identity.certificate.certificate.actorDeviceId,
274
+ scopes
275
+ })
276
+
277
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
278
+ throw new Error(`Sync offline bootstrap did not return a response object (${this.config.bootstrapPath})`)
279
+ }
280
+
281
+ const responseRecord = /** @type {Record<string, unknown>} */ (response)
282
+ const signedGrant = /** @type {import("./offline-grant.js").SignedOfflineGrant | undefined} */ (responseRecord.offlineGrant)
283
+
284
+ if (!signedGrant || typeof signedGrant !== "object" || !signedGrant.grant || typeof signedGrant.grant.grantId !== "string") {
285
+ throw new Error(`Sync offline bootstrap did not return a signed offline grant (${this.config.bootstrapPath})`)
286
+ }
287
+
288
+ const manifest = /** @type {Record<string, import("../configuration-types.js").NormalizedFrontendModelResourceSyncConfiguration> | undefined} */ (responseRecord.syncManifest)
289
+
290
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
291
+ throw new Error(`Sync offline bootstrap did not return a sync manifest (${this.config.bootstrapPath})`)
292
+ }
293
+
294
+ this._grants.set(signedGrant.grant.grantId, signedGrant)
295
+ this._activeGrant = signedGrant
296
+ this._syncManifest = manifest
297
+ await this._persistGrants()
298
+
299
+ return signedGrant
300
+ }
301
+
302
+ /**
303
+ * Forces a grant refresh: bootstrap a new grant and re-sign this device's
304
+ * pending records under it.
305
+ * @returns {Promise<SyncOfflineSessionSummary>} - Refreshed session summary.
306
+ */
307
+ async refreshGrant() {
308
+ await this._ensureIdentity()
309
+ await this._loadRetainedGrants()
310
+
311
+ const granted = await this.bootstrapGrant()
312
+
313
+ await this._resignPendingRecords(granted)
314
+
315
+ return this.sessionSummary()
316
+ }
317
+
318
+ /**
319
+ * Re-signs this device's still-pending records under a new grant. Safe
320
+ * because the replay idempotency key is
321
+ * `actorUserId:actorDeviceId:clientMutationId` and never carries the grant
322
+ * id. Records signed by another device (peer-forwarded mutations) keep their
323
+ * original signed envelope and original grant - they must always replay with
324
+ * the grant their signer used.
325
+ * @param {import("./offline-grant.js").SignedOfflineGrant} signedGrant - Grant the records are re-signed under.
326
+ * @returns {Promise<void>} - Resolves after pending records are re-signed.
327
+ */
328
+ async _resignPendingRecords(signedGrant) {
329
+ this._requireIdentity()
330
+
331
+ for (const mutationLog of this.mutationLogs) {
332
+ for (const record of await mutationLog.pendingRecords()) {
333
+ if (!this._isOwnRecord(record)) continue
334
+ if (record.mutation.offlineGrantId === signedGrant.grant.grantId && record.signedMutation) continue
335
+
336
+ const signedMutation = await this.signMutation(record.mutation)
337
+
338
+ await mutationLog.updateMutation({id: record.id, mutation: signedMutation.mutation, signedMutation})
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Checks whether a log record was signed/queued by this device's identity
345
+ * (or has no identity yet and can adopt it).
346
+ * @param {import("./local-mutation-log.js").LocalMutationLogRecord} record - Durable record.
347
+ * @returns {boolean} - Whether the record belongs to this device.
348
+ */
349
+ _isOwnRecord(record) {
350
+ const identity = this._requireIdentity()
351
+
352
+ if (record.signedMutation) {
353
+ return record.signedMutation.mutation.actorDeviceId === identity.certificate.certificate.actorDeviceId &&
354
+ record.signedMutation.mutation.actorUserId === identity.certificate.certificate.actorUserId
355
+ }
356
+
357
+ return !record.mutation.actorDeviceId || (
358
+ record.mutation.actorDeviceId === identity.certificate.certificate.actorDeviceId &&
359
+ record.mutation.actorUserId === identity.certificate.certificate.actorUserId
360
+ )
361
+ }
362
+
363
+ /**
364
+ * Signs one mutation payload with the device key under the live grant,
365
+ * filling the actor/grant/policy-hash identity from the session.
366
+ * @param {Omit<import("./device-identity.js").SyncMutation, "actorDeviceId" | "actorUserId" | "offlineGrantId" | "policyHash">} mutation - Mutation payload to sign (the session supplies the rotating identity fields).
367
+ * @returns {Promise<import("./device-identity.js").SignedSyncMutation>} - Signed mutation envelope.
368
+ */
369
+ async signMutation(mutation) {
370
+ const identity = await this._ensureIdentity()
371
+ const grant = await this._grantForSigning()
372
+
373
+ return await createSignedMutation({
374
+ deviceCertificate: identity.certificate,
375
+ devicePrivateKey: identity.privateKey,
376
+ mutation: {
377
+ ...mutation,
378
+ actorDeviceId: identity.certificate.certificate.actorDeviceId,
379
+ actorUserId: identity.certificate.certificate.actorUserId,
380
+ offlineGrantId: grant.grant.grantId,
381
+ policyHash: this.policyHashFor(mutation.model)
382
+ }
383
+ })
384
+ }
385
+
386
+ /**
387
+ * Returns the grant a new mutation is signed under: the live grant when one
388
+ * exists, otherwise the newest retained grant (queueing stays possible while
389
+ * offline - the replay pass refreshes the grant and re-signs the pending
390
+ * records), and only bootstraps when no grant has ever been retained.
391
+ * @returns {Promise<import("./offline-grant.js").SignedOfflineGrant>} - Grant to sign under.
392
+ */
393
+ async _grantForSigning() {
394
+ const live = this._liveGrant()
395
+
396
+ if (live) return live
397
+ if (this._activeGrant) return this._activeGrant
398
+
399
+ await this._loadRetainedGrants()
400
+
401
+ const retained = Array.from(this._grants.values()).sort((left, right) => new Date(right.grant.issuedAt).getTime() - new Date(left.grant.issuedAt).getTime())
402
+
403
+ if (retained.length > 0) {
404
+ this._activeGrant = retained[0]
405
+
406
+ return this._activeGrant
407
+ }
408
+
409
+ return await this.bootstrapGrant()
410
+ }
411
+
412
+ /**
413
+ * Returns the policy hash the live session's sync manifest carries for one model.
414
+ * @param {string} model - Model/resource name.
415
+ * @returns {string} - Policy hash.
416
+ */
417
+ policyHashFor(model) {
418
+ const manifest = this._syncManifest || this._activeGrant?.grant.resources
419
+
420
+ if (!manifest) throw new Error("Sync offline session has no sync manifest - call startOfflineSession() first")
421
+
422
+ const entry = /** @type {Record<string, unknown> | undefined} */ (manifest[model])
423
+ const policyHash = entry && typeof entry === "object" && !Array.isArray(entry) ? /** @type {Record<string, unknown>} */ (entry).policyHash : undefined
424
+
425
+ if (typeof policyHash !== "string" || policyHash.length === 0) {
426
+ throw new Error(`Sync offline session has no policy hash for model '${model}' - is it sync-enabled on the backend?`)
427
+ }
428
+
429
+ return policyHash
430
+ }
431
+
432
+ /**
433
+ * Drains the durable mutation logs through the built-in signed replay
434
+ * endpoint. Pending records are batched, each signed mutation travels with
435
+ * the signed grant it was signed under, and per-mutation results are mapped
436
+ * back onto the log records as synced/conflict/rejected.
437
+ * @param {object} [options] - Replay options.
438
+ * @param {number} [options.batchSize] - Max mutations per request.
439
+ * @param {AbortSignal} [options.signal] - Lifecycle cancellation signal.
440
+ * @returns {Promise<SyncOfflineReplayResult>} - Mapped replay result.
441
+ */
442
+ async replayPending(options = {}) {
443
+ if (this._replayPromise) return await this._replayPromise
444
+
445
+ this._replayPromise = this._replayPass(options).finally(() => {
446
+ this._replayPromise = null
447
+ })
448
+
449
+ return await this._replayPromise
450
+ }
451
+
452
+ /**
453
+ * Runs one signed replay pass.
454
+ * @param {object} options - Pass options.
455
+ * @param {number} [options.batchSize] - Max mutations per request.
456
+ * @param {AbortSignal} [options.signal] - Lifecycle cancellation signal.
457
+ * @returns {Promise<SyncOfflineReplayResult>} - Mapped replay result.
458
+ */
459
+ async _replayPass({batchSize, signal} = {}) {
460
+ throwIfAborted(signal)
461
+
462
+ if (!this.isActive()) await this.start()
463
+
464
+ const maxBatchSize = typeof batchSize === "number" && batchSize >= 1 ? Math.floor(batchSize) : 100
465
+ /** @type {SyncOfflineReplayResult["results"]} */
466
+ const results = []
467
+ let syncedCount = 0
468
+ let conflictCount = 0
469
+ let rejectedCount = 0
470
+
471
+ for (let pass = 0; pass < 10; pass += 1) {
472
+ throwIfAborted(signal)
473
+ await this._ensureLiveGrant()
474
+
475
+ /** @type {Array<{mutationLog: import("./local-mutation-log.js").default, record: import("./local-mutation-log.js").LocalMutationLogRecord}>} */
476
+ const entries = []
477
+
478
+ for (const mutationLog of this.mutationLogs) {
479
+ for (const record of await mutationLog.pendingRecords()) {
480
+ if (!record.signedMutation) continue
481
+ // A record can only replay with the grant it was signed under. Records
482
+ // whose grant the session does not retain (e.g. peer imports without
483
+ // their signer's grant) stay pending instead of blocking the batch.
484
+ if (!this._grants.has(record.mutation.offlineGrantId)) continue
485
+
486
+ entries.push({mutationLog, record})
487
+ }
488
+ }
489
+
490
+ if (entries.length === 0) break
491
+
492
+ const batch = entries.slice(0, maxBatchSize)
493
+ const authenticationToken = await this.authenticationToken()
494
+ const mutations = batch.map((entry) => this._replayMutation(entry.record))
495
+ const response = await this.post(this.config.replayPath, {authenticationToken, mutations}, {signal})
496
+
497
+ throwIfAborted(signal)
498
+
499
+ const resultsByIdempotencyKey = this._replayResultsById(response)
500
+
501
+ for (const {mutationLog, record} of batch) {
502
+ throwIfAborted(signal)
503
+ const idempotencyKey = mutationIdempotencyKey(/** @type {import("./device-identity.js").SignedSyncMutation} */ (record.signedMutation))
504
+ const result = resultsByIdempotencyKey.get(idempotencyKey)
505
+
506
+ if (!result) throw new Error(`Signed sync replay response is missing a result for mutation ${record.mutation.clientMutationId}`)
507
+
508
+ const status = replayResultLocalStatus(/** @type {Record<string, import("./conflict-strategy.js").SyncJsonValue>} */ (result))
509
+ const serverResult = cloneJson(result, "sync replay result")
510
+ const updated = await mutationLog.updateStatus({
511
+ id: record.id,
512
+ status,
513
+ syncResult: /** @type {Record<string, import("../frontend-models/base.js").FrontendModelTransportValue>} */ (serverResult)
514
+ })
515
+
516
+ results.push({
517
+ clientMutationId: record.mutation.clientMutationId,
518
+ idempotencyKey,
519
+ mutation: /** @type {import("./device-identity.js").SyncMutation} */ (cloneJson(record.mutation, "mutation")),
520
+ recordId: updated.id,
521
+ serverResult,
522
+ status: updated.status
523
+ })
524
+
525
+ if (updated.status === "synced") syncedCount += 1
526
+ if (updated.status === "conflict") conflictCount += 1
527
+ if (updated.status === "rejected") rejectedCount += 1
528
+ }
529
+ }
530
+
531
+ let pendingCount = 0
532
+
533
+ for (const mutationLog of this.mutationLogs) {
534
+ pendingCount += (await mutationLog.pendingRecords()).length
535
+ }
536
+
537
+ return {conflictCount, pendingCount, rejectedCount, results, syncedCount}
538
+ }
539
+
540
+ /**
541
+ * Builds one signed replay mutation: the signed envelope plus the signed
542
+ * grant the mutation was signed under. Fails closed when the session does
543
+ * not retain that grant.
544
+ * @param {import("./local-mutation-log.js").LocalMutationLogRecord} record - Durable record.
545
+ * @returns {Record<string, ReturnType<typeof JSON.parse>>} - Replay mutation payload.
546
+ */
547
+ _replayMutation(record) {
548
+ const signedMutation = /** @type {import("./device-identity.js").SignedSyncMutation} */ (record.signedMutation)
549
+ const signedGrant = this._grants.get(signedMutation.mutation.offlineGrantId)
550
+
551
+ if (!signedGrant) throw new Error(`Sync offline session has no retained grant '${signedMutation.mutation.offlineGrantId}' for mutation ${signedMutation.mutation.clientMutationId}`)
552
+
553
+ return {...cloneJson(signedMutation, "signed mutation"), signedOfflineGrant: cloneJson(signedGrant, "signed offline grant")}
554
+ }
555
+
556
+ /**
557
+ * Normalizes a signed replay response into results keyed by idempotency key.
558
+ * @param {ReturnType<typeof JSON.parse>} response - Raw replay response.
559
+ * @returns {Map<string, Record<string, ReturnType<typeof JSON.parse>>>} - Results by idempotency key.
560
+ */
561
+ _replayResultsById(response) {
562
+ if (!response || typeof response !== "object" || Array.isArray(response)) throw new Error("Signed sync replay response must be an object")
563
+
564
+ const responseRecord = /** @type {Record<string, unknown>} */ (response)
565
+
566
+ if (responseRecord.status === "error") throw new Error(typeof responseRecord.errorMessage === "string" ? responseRecord.errorMessage : "Signed sync replay failed")
567
+ if (!Array.isArray(responseRecord.results)) throw new Error("Signed sync replay response is missing results")
568
+
569
+ /** @type {Map<string, Record<string, ReturnType<typeof JSON.parse>>>} */
570
+ const byKey = new Map()
571
+
572
+ for (const result of responseRecord.results) {
573
+ if (!result || typeof result !== "object" || Array.isArray(result)) continue
574
+
575
+ const resultRecord = /** @type {Record<string, unknown>} */ (result)
576
+
577
+ if (typeof resultRecord.idempotencyKey !== "string") continue
578
+
579
+ byKey.set(resultRecord.idempotencyKey, /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (resultRecord))
580
+ }
581
+
582
+ return byKey
583
+ }
584
+
585
+ /**
586
+ * Exports the durable non-terminal mutations of every session log as one
587
+ * signed peer bundle.
588
+ * @param {object} [options] - Export options.
589
+ * @param {import("./local-mutation-log.js").LocalMutationStatus[]} [options.statuses] - Statuses to export.
590
+ * @param {() => Date} [options.now] - Export clock.
591
+ * @returns {Promise<import("./peer-mutation-bundle.js").PeerMutationBundle>} - Signed peer bundle.
592
+ */
593
+ async exportBundle(options = {}) {
594
+ const identity = await this._ensureIdentity()
595
+ /** @type {import("./peer-mutation-bundle.js").PeerMutationBundle["mutations"]} */
596
+ const mutations = []
597
+ let bundle = null
598
+
599
+ for (const mutationLog of this.mutationLogs) {
600
+ const logBundle = await exportPeerMutationBundle({
601
+ deviceCertificate: identity.certificate,
602
+ devicePrivateKey: identity.privateKey,
603
+ mutationLog,
604
+ ...options
605
+ })
606
+
607
+ if (!bundle) bundle = logBundle
608
+ else mutations.push(...logBundle.mutations)
609
+ }
610
+
611
+ if (!bundle) {
612
+ throw new Error("Sync offline session export produced no bundle")
613
+ }
614
+
615
+ bundle.mutations = mutations.length > 0 ? [...bundle.mutations, ...mutations].sort((left, right) => (left.localSequence || 0) - (right.localSequence || 0)) : bundle.mutations
616
+
617
+ return bundle
618
+ }
619
+
620
+ /**
621
+ * Imports a peer bundle: every entry is verified against the configured
622
+ * backend public key before it is appended to a local mutation log. Entries
623
+ * already present by idempotency key are skipped as duplicates.
624
+ * @param {object} args - Import args.
625
+ * @param {import("./peer-mutation-bundle.js").PeerMutationBundle} args.bundle - Peer bundle to import.
626
+ * @param {Array<import("./offline-grant.js").SignedOfflineGrant>} [args.signedOfflineGrants] - Grants the peer signed its mutations under. They are retained so imported mutations replay with the grant their signer used.
627
+ * @param {import("./local-mutation-log.js").default} [args.mutationLog] - Target log. Defaults to the first session log.
628
+ * @returns {Promise<import("./peer-mutation-bundle.js").PeerMutationBundleImportResult>} - Import result.
629
+ */
630
+ async importBundle({bundle, mutationLog, signedOfflineGrants = []}) {
631
+ if (!this.config.backendPublicKey) throw new Error("sync.client.offline.backendPublicKey is required to import a peer mutation bundle")
632
+ if (!Array.isArray(signedOfflineGrants)) throw new Error("sync offline import signedOfflineGrants must be an array of signed offline grants")
633
+
634
+ for (const signedGrant of signedOfflineGrants) {
635
+ if (!signedGrant || typeof signedGrant !== "object" || !signedGrant.grant || typeof signedGrant.grant.grantId !== "string") {
636
+ throw new Error("sync offline import requires signed offline grant envelopes with grant payloads")
637
+ }
638
+
639
+ this._grants.set(signedGrant.grant.grantId, signedGrant)
640
+ }
641
+
642
+ if (signedOfflineGrants.length > 0) await this._persistGrants()
643
+
644
+ return await importPeerMutationBundle({
645
+ backendPublicKey: this.config.backendPublicKey,
646
+ bundle,
647
+ mutationLog: mutationLog || this.mutationLogs[0]
648
+ })
649
+ }
650
+
651
+ /**
652
+ * Persists retained grants through the optional app-owned grant storage.
653
+ * @returns {Promise<void>} - Resolves after retention is persisted.
654
+ */
655
+ async _persistGrants() {
656
+ if (!this.config.grantStorage) return
657
+
658
+ await this.config.grantStorage.save(Array.from(this._grants.values()))
659
+ }
660
+
661
+ /**
662
+ * Returns the session clock.
663
+ * @returns {Date} - Current time.
664
+ */
665
+ _now() {
666
+ return this.config.now()
667
+ }
668
+
669
+ /**
670
+ * Returns the device identity or fails closed.
671
+ * @returns {SyncOfflineDeviceIdentity} - Device identity.
672
+ */
673
+ _requireIdentity() {
674
+ if (!this._identity) throw new Error("Sync offline session has no device identity - call startOfflineSession() first")
675
+
676
+ return this._identity
677
+ }
678
+
679
+ /**
680
+ * Returns the live grant or fails closed.
681
+ * @returns {import("./offline-grant.js").SignedOfflineGrant} - Live grant.
682
+ */
683
+ _requireLiveGrant() {
684
+ const grant = this._liveGrant()
685
+
686
+ if (!grant) throw new Error("Sync offline session has no live offline grant - call startOfflineSession() or refreshOfflineGrant()")
687
+
688
+ return grant
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Clones a JSON object.
694
+ * @param {unknown} value - Value.
695
+ * @param {string} label - Diagnostic label.
696
+ * @returns {Record<string, ReturnType<typeof JSON.parse>>} - Cloned object.
697
+ */
698
+ function cloneJson(value, label) {
699
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Expected sync offline ${label} object`)
700
+
701
+ return /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (JSON.parse(JSON.stringify(value)))
702
+ }
703
+
704
+ /**
705
+ * Throws the exact abort reason at lifecycle boundaries.
706
+ * @param {AbortSignal | undefined} signal - Lifecycle signal.
707
+ * @returns {void} - No return value.
708
+ */
709
+ function throwIfAborted(signal) {
710
+ if (!signal?.aborted) return
711
+
712
+ throw signal.reason instanceof Error ? signal.reason : new Error("Sync offline replay aborted")
713
+ }