velocious 1.0.476 → 1.0.478

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 +3 -0
  3. package/build/configuration.js +24 -0
  4. package/build/frontend-model-controller.js +368 -11
  5. package/build/frontend-models/base.js +5 -1
  6. package/build/frontend-models/resource-definition.js +20 -3
  7. package/build/http-server/client/websocket-session.js +93 -0
  8. package/build/routes/hooks/frontend-model-command-route-hook.js +16 -0
  9. package/build/src/configuration-types.d.ts +15 -0
  10. package/build/src/configuration-types.d.ts.map +1 -1
  11. package/build/src/configuration-types.js +4 -1
  12. package/build/src/configuration.d.ts +13 -0
  13. package/build/src/configuration.d.ts.map +1 -1
  14. package/build/src/configuration.js +23 -1
  15. package/build/src/frontend-model-controller.d.ts +96 -2
  16. package/build/src/frontend-model-controller.d.ts.map +1 -1
  17. package/build/src/frontend-model-controller.js +319 -12
  18. package/build/src/frontend-models/base.d.ts +5 -0
  19. package/build/src/frontend-models/base.d.ts.map +1 -1
  20. package/build/src/frontend-models/base.js +6 -2
  21. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  22. package/build/src/frontend-models/resource-definition.js +20 -4
  23. package/build/src/http-server/client/websocket-session.d.ts +39 -0
  24. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  25. package/build/src/http-server/client/websocket-session.js +85 -1
  26. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  27. package/build/src/routes/hooks/frontend-model-command-route-hook.js +15 -1
  28. package/build/src/sync/conflict-strategy.d.ts +83 -0
  29. package/build/src/sync/conflict-strategy.d.ts.map +1 -0
  30. package/build/src/sync/conflict-strategy.js +215 -0
  31. package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
  32. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
  33. package/build/src/sync/peer-mutation-bundle.js +215 -0
  34. package/build/src/sync/server-change-feed.d.ts +265 -0
  35. package/build/src/sync/server-change-feed.d.ts.map +1 -0
  36. package/build/src/sync/server-change-feed.js +475 -0
  37. package/build/src/sync/sync-envelope-replay-service.d.ts +213 -0
  38. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -0
  39. package/build/src/sync/sync-envelope-replay-service.js +229 -0
  40. package/build/sync/conflict-strategy.js +225 -0
  41. package/build/sync/peer-mutation-bundle.js +232 -0
  42. package/build/sync/server-change-feed.js +524 -0
  43. package/build/sync/sync-envelope-replay-service.js +263 -0
  44. package/build/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +1 -1
  46. package/src/configuration-types.js +3 -0
  47. package/src/configuration.js +24 -0
  48. package/src/frontend-model-controller.js +368 -11
  49. package/src/frontend-models/base.js +5 -1
  50. package/src/frontend-models/resource-definition.js +20 -3
  51. package/src/http-server/client/websocket-session.js +93 -0
  52. package/src/routes/hooks/frontend-model-command-route-hook.js +16 -0
  53. package/src/sync/conflict-strategy.js +225 -0
  54. package/src/sync/peer-mutation-bundle.js +232 -0
  55. package/src/sync/server-change-feed.js +524 -0
  56. package/src/sync/sync-envelope-replay-service.js +263 -0
@@ -199,6 +199,24 @@ export default class VelociousHttpServerClientWebsocketSession {
199
199
  this._fragmentedBytes = 0
200
200
 
201
201
  this.configuration._websocketSessions.add(this)
202
+
203
+ /**
204
+ * Heartbeat liveness flag. Set true on every inbound frame
205
+ * (including the client's auto-pong) and cleared each time a ping
206
+ * is sent; a still-false flag at the next tick means the socket
207
+ * has gone silent.
208
+ * @type {boolean}
209
+ */
210
+ this._heartbeatAlive = true
211
+
212
+ /**
213
+ * Per-session heartbeat interval handle. Started from
214
+ * `sendSessionEstablished` once the socket is live, not at
215
+ * construction, so directly-constructed sessions in tests don't
216
+ * spin up a background timer.
217
+ * @type {ReturnType<typeof setInterval> | null}
218
+ */
219
+ this._heartbeatTimer = null
202
220
  }
203
221
 
204
222
  /**
@@ -212,6 +230,9 @@ export default class VelociousHttpServerClientWebsocketSession {
212
230
  sessionId: this.sessionId,
213
231
  graceSeconds: this.configuration.getWebsocketSessionGraceSeconds?.() || 300
214
232
  })
233
+
234
+ // The socket is live now, so begin reaping it if it goes silent.
235
+ this._startHeartbeat()
215
236
  }
216
237
 
217
238
  /**
@@ -251,6 +272,7 @@ export default class VelociousHttpServerClientWebsocketSession {
251
272
  }
252
273
 
253
274
  destroy() {
275
+ this._stopHeartbeat()
254
276
  this.configuration._websocketSessions.delete(this)
255
277
  this._paused = false
256
278
  void this._teardownChannel()
@@ -274,6 +296,11 @@ export default class VelociousHttpServerClientWebsocketSession {
274
296
  * @returns {void} - No return value.
275
297
  */
276
298
  onData(data) {
299
+ // Any inbound bytes — a data frame, the auto-pong answering our
300
+ // heartbeat, or a partial frame still being uploaded — prove the
301
+ // socket is alive. Mark it here, before `_processBuffer` may return
302
+ // early waiting for the rest of an incomplete frame.
303
+ this._heartbeatAlive = true
277
304
  this.buffer = Buffer.concat([this.buffer, data])
278
305
  this._processBuffer()
279
306
  }
@@ -611,6 +638,11 @@ export default class VelociousHttpServerClientWebsocketSession {
611
638
  continue
612
639
  }
613
640
 
641
+ if (opcode === WEBSOCKET_OPCODE_PONG) {
642
+ // Answer to a heartbeat ping; liveness is recorded in onData.
643
+ continue
644
+ }
645
+
614
646
  if (opcode >= 0x8) {
615
647
  this.logger.warn(`Unsupported websocket control opcode: ${opcode}`)
616
648
  continue
@@ -756,6 +788,60 @@ export default class VelociousHttpServerClientWebsocketSession {
756
788
  this._fragmentedBytes = 0
757
789
  }
758
790
 
791
+ /**
792
+ * Starts the per-session heartbeat. Each tick pings the client and
793
+ * reaps the session if the previous ping went unanswered, so a
794
+ * half-open socket (client gone without a TCP FIN / close frame)
795
+ * cannot linger forever holding channel subscriptions. Disabled when
796
+ * the configured interval is 0.
797
+ * @returns {void}
798
+ */
799
+ _startHeartbeat() {
800
+ const intervalSeconds = this.configuration.getWebsocketSessionHeartbeatSeconds()
801
+
802
+ if (!intervalSeconds || intervalSeconds <= 0) return
803
+
804
+ this._heartbeatTimer = setInterval(() => this._heartbeatTick(), intervalSeconds * 1000)
805
+
806
+ // Don't let the heartbeat timer keep the process alive.
807
+ if (typeof this._heartbeatTimer.unref === "function") this._heartbeatTimer.unref()
808
+ }
809
+
810
+ /**
811
+ * One heartbeat cycle. Reaps the session via the normal close path
812
+ * when the previous ping was not answered; otherwise marks it
813
+ * pending and pings again. Browsers and React Native sockets answer
814
+ * server pings with an automatic pong, which lands in `_processBuffer`
815
+ * and re-marks the session alive.
816
+ * @returns {void}
817
+ */
818
+ _heartbeatTick() {
819
+ if (this._paused || !this.client?.events) return
820
+
821
+ if (!this._heartbeatAlive) {
822
+ // No frame arrived since the last ping — the socket is dead.
823
+ // Route through `_handleClose` so resumable state still pauses
824
+ // for the grace window and everything else is torn down.
825
+ this._stopHeartbeat()
826
+ this._handleClose()
827
+ return
828
+ }
829
+
830
+ this._heartbeatAlive = false
831
+ this._sendControlFrame(WEBSOCKET_OPCODE_PING, Buffer.alloc(0))
832
+ }
833
+
834
+ /**
835
+ * Stops the per-session heartbeat timer, if any.
836
+ * @returns {void}
837
+ */
838
+ _stopHeartbeat() {
839
+ if (this._heartbeatTimer) {
840
+ clearInterval(this._heartbeatTimer)
841
+ this._heartbeatTimer = null
842
+ }
843
+ }
844
+
759
845
  /**
760
846
  * Runs send control frame.
761
847
  * @param {number} opcode - Opcode.
@@ -891,6 +977,9 @@ export default class VelociousHttpServerClientWebsocketSession {
891
977
  const hasResumableState = this._connections.size > 0 || this._channelSubscriptions.size > 0
892
978
 
893
979
  if (hasResumableState && !this._paused) {
980
+ // Paused sessions have no live socket to ping; the grace timer
981
+ // owns their eventual teardown from here.
982
+ this._stopHeartbeat()
894
983
  this._paused = true
895
984
  this.socket = null
896
985
  // Kick off auth-identity capture for resume verification. Runs
@@ -905,6 +994,8 @@ export default class VelociousHttpServerClientWebsocketSession {
905
994
  return
906
995
  }
907
996
 
997
+ this._stopHeartbeat()
998
+ this.configuration._websocketSessions.delete(this)
908
999
  void this._runMessageHandlerClose()
909
1000
  void this._teardownChannel()
910
1001
  void this._teardownConnections("session_destroyed")
@@ -919,6 +1010,8 @@ export default class VelociousHttpServerClientWebsocketSession {
919
1010
  * @returns {void}
920
1011
  */
921
1012
  _finalizeGraceExpiry() {
1013
+ this._stopHeartbeat()
1014
+ this.configuration._websocketSessions.delete(this)
922
1015
  void this._runMessageHandlerClose()
923
1016
  void this._teardownChannel()
924
1017
  void this._teardownConnections("grace_expired")
@@ -33,6 +33,22 @@ export default async function frontendModelCommandRouteHook({configuration, curr
33
33
  }
34
34
  }
35
35
 
36
+ if (["/frontend-models/sync/change-feed", "/frontend-models/sync/changes", "/sync/changes"].includes(normalizedCurrentPath)) {
37
+ return {
38
+ action: "frontend-sync-change-feed",
39
+ controller: "velocious/api",
40
+ controllerPath: FRONTEND_MODEL_CONTROLLER_PATH
41
+ }
42
+ }
43
+
44
+ if (["/frontend-models/sync/snapshot", "/sync/snapshot"].includes(normalizedCurrentPath)) {
45
+ return {
46
+ action: "frontend-sync-snapshot",
47
+ controller: "velocious/api",
48
+ controllerPath: FRONTEND_MODEL_CONTROLLER_PATH
49
+ }
50
+ }
51
+
36
52
  if (normalizedCurrentPath === SHARED_FRONTEND_MODEL_API_PATH) {
37
53
  return {
38
54
  action: "frontend-api",
@@ -0,0 +1,225 @@
1
+ // @ts-check
2
+
3
+ const CONFLICT_STRATEGIES = new Set(["optimisticVersion", "serverWins", "lastWriterWins", "fieldThreeWay", "appendOnly"])
4
+
5
+ /**
6
+ * @typedef {null | string | number | boolean | unknown[] | Record<string, unknown>} SyncJsonValue
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} SyncConflictRecord
11
+ * @property {Record<string, SyncJsonValue>} attributes - Record attributes.
12
+ * @property {string | number | boolean | null} [version] - Record version value.
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} SyncConflictResult
17
+ * @property {Record<string, SyncJsonValue>} [attributes] - Attributes to apply when replay may continue.
18
+ * @property {Record<string, SyncJsonValue>} [conflict] - Structured conflict payload for the client/local log.
19
+ * @property {"applied" | "conflict" | "rejected"} status - Conflict decision.
20
+ * @property {string} strategy - Strategy that produced the decision.
21
+ */
22
+
23
+ /**
24
+ * Evaluates a replay mutation against server/base state using a sync conflict strategy.
25
+ * @param {object} args - Arguments.
26
+ * @param {SyncConflictRecord | null} [args.baseRecord] - Record state observed when the mutation was made.
27
+ * @param {function(object): (SyncConflictResult | Promise<SyncConflictResult>)} [args.customHandler] - Resource-specific conflict hook.
28
+ * @param {import("./device-identity.js").SyncMutation} args.mutation - Replayed mutation.
29
+ * @param {SyncConflictRecord | null} [args.serverRecord] - Current authoritative server record.
30
+ * @param {string} [args.strategy] - Conflict strategy.
31
+ * @param {string} [args.versionAttribute] - Attribute used for optimistic version checks.
32
+ * @returns {Promise<SyncConflictResult>} - Conflict decision.
33
+ */
34
+ export async function resolveSyncConflict({baseRecord = null, customHandler, mutation, serverRecord = null, strategy = "optimisticVersion", versionAttribute = "updatedAt"}) {
35
+ if (customHandler) return normalizeConflictResult(await customHandler({baseRecord, mutation, serverRecord, strategy, versionAttribute}), strategy)
36
+ if (!CONFLICT_STRATEGIES.has(strategy)) throw new Error(`Unknown sync conflict strategy '${strategy}'`)
37
+
38
+ const mutationAttributes = mutationAttributesFor(mutation)
39
+
40
+ if (strategy === "appendOnly" || strategy === "lastWriterWins") return {attributes: mutationAttributes, status: "applied", strategy}
41
+ if (strategy === "fieldThreeWay") return fieldThreeWayResult({baseRecord, mutation, serverRecord, strategy, versionAttribute})
42
+ if (hasVersionConflict({mutation, serverRecord, versionAttribute})) return conflictResult({baseRecord, mutation, serverRecord, strategy, versionAttribute})
43
+
44
+ return {attributes: mutationAttributes, status: "applied", strategy}
45
+ }
46
+
47
+ /**
48
+ * Applies a server replay result to a local mutation-log record.
49
+ * @param {object} args - Arguments.
50
+ * @param {import("./local-mutation-log.js").default} args.mutationLog - Local mutation log.
51
+ * @param {import("./local-mutation-log.js").LocalMutationLogRecord} args.record - Local mutation-log record.
52
+ * @param {Record<string, SyncJsonValue>} args.result - Server replay result payload.
53
+ * @returns {Promise<import("./local-mutation-log.js").LocalMutationLogRecord>} - Updated local record.
54
+ */
55
+ export async function applySyncReplayResultToLocalMutationLog({mutationLog, record, result}) {
56
+ return await mutationLog.updateStatus({
57
+ id: record.id,
58
+ status: replayResultLocalStatus(result),
59
+ syncResult: result
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Maps a server replay result to a local mutation-log status.
65
+ * @param {Record<string, SyncJsonValue>} result - Replay result.
66
+ * @returns {import("./local-mutation-log.js").LocalMutationStatus} - Local mutation-log status.
67
+ */
68
+ export function replayResultLocalStatus(result) {
69
+ if (result.status === "conflict") return "conflict"
70
+ if (result.status === "error" || result.status === "rejected") return "rejected"
71
+ if (result.status === "success" && replayResultHasFailedApplication(result)) return "rejected"
72
+ if (result.status === "success" || result.status === "applied" || result.status === "duplicate") return "synced"
73
+
74
+ throw new Error(`Unknown sync replay result status '${String(result.status)}'`)
75
+ }
76
+
77
+ /**
78
+ * Checks whether an otherwise successful replay result contains a failed
79
+ * frontend-model command or change-feed write.
80
+ * @param {Record<string, SyncJsonValue>} result - Replay result.
81
+ * @returns {boolean} - Whether the local mutation must stay unsynced.
82
+ */
83
+ function replayResultHasFailedApplication(result) {
84
+ if (result.serverSequence === null || result.serverChangeFeedStatus === "error") return true
85
+ if (!result.response || typeof result.response !== "object" || Array.isArray(result.response)) return false
86
+
87
+ return /** @type {Record<string, SyncJsonValue>} */ (result.response).status === "error"
88
+ }
89
+
90
+ /**
91
+ * Runs a field-level three-way merge.
92
+ * @param {object} args - Arguments.
93
+ * @param {SyncConflictRecord | null} args.baseRecord - Base record.
94
+ * @param {import("./device-identity.js").SyncMutation} args.mutation - Mutation.
95
+ * @param {SyncConflictRecord | null} args.serverRecord - Server record.
96
+ * @param {string} args.strategy - Strategy.
97
+ * @param {string} args.versionAttribute - Version attribute.
98
+ * @returns {SyncConflictResult} - Conflict decision.
99
+ */
100
+ function fieldThreeWayResult({baseRecord, mutation, serverRecord, strategy, versionAttribute}) {
101
+ if (!baseRecord || !serverRecord) return {attributes: mutationAttributesFor(mutation), status: "applied", strategy}
102
+
103
+ const mutationAttributes = mutationAttributesFor(mutation)
104
+ /** @type {string[]} */
105
+ const affectedFields = []
106
+ /** @type {Record<string, SyncJsonValue>} */
107
+ const mergedAttributes = {}
108
+
109
+ for (const [field, localValue] of Object.entries(mutationAttributes)) {
110
+ const baseValue = baseRecord.attributes[field]
111
+ const serverValue = serverRecord.attributes[field]
112
+
113
+ if (jsonValuesEqual(serverValue, baseValue) || jsonValuesEqual(serverValue, localValue)) {
114
+ mergedAttributes[field] = localValue
115
+ } else {
116
+ affectedFields.push(field)
117
+ }
118
+ }
119
+
120
+ if (affectedFields.length > 0) return conflictResult({affectedFields, baseRecord, mutation, serverRecord, strategy, versionAttribute})
121
+
122
+ return {attributes: mergedAttributes, status: "applied", strategy}
123
+ }
124
+
125
+ /**
126
+ * Checks whether current server state conflicts with a mutation base version.
127
+ * @param {object} args - Arguments.
128
+ * @param {import("./device-identity.js").SyncMutation} args.mutation - Mutation.
129
+ * @param {SyncConflictRecord | null} args.serverRecord - Server record.
130
+ * @param {string} args.versionAttribute - Version attribute.
131
+ * @returns {boolean} - Whether versions conflict.
132
+ */
133
+ function hasVersionConflict({mutation, serverRecord, versionAttribute}) {
134
+ if (!serverRecord) return false
135
+ if (mutation.baseVersion === undefined || mutation.baseVersion === null) return false
136
+
137
+ const serverVersion = serverRecord.version ?? serverRecord.attributes[versionAttribute] ?? null
138
+
139
+ return !jsonValuesEqual(serverVersion, mutation.baseVersion)
140
+ }
141
+
142
+ /**
143
+ * Builds a structured conflict result.
144
+ * @param {object} args - Arguments.
145
+ * @param {string[]} [args.affectedFields] - Explicit affected fields.
146
+ * @param {SyncConflictRecord | null} args.baseRecord - Base record.
147
+ * @param {import("./device-identity.js").SyncMutation} args.mutation - Mutation.
148
+ * @param {SyncConflictRecord | null} args.serverRecord - Server record.
149
+ * @param {string} args.strategy - Strategy.
150
+ * @param {string} args.versionAttribute - Version attribute.
151
+ * @returns {SyncConflictResult} - Conflict result.
152
+ */
153
+ function conflictResult({affectedFields, baseRecord, mutation, serverRecord, strategy, versionAttribute}) {
154
+ const mutationAttributes = mutationAttributesFor(mutation)
155
+
156
+ return {
157
+ conflict: {
158
+ affectedFields: affectedFields || Object.keys(mutationAttributes),
159
+ baseRecord: baseRecord ? baseRecord.attributes : null,
160
+ baseVersion: /** @type {SyncJsonValue} */ (mutation.baseVersion ?? null),
161
+ localMutation: {
162
+ attributes: mutationAttributes,
163
+ clientMutationId: mutation.clientMutationId,
164
+ model: mutation.model,
165
+ operation: mutation.operation,
166
+ payload: /** @type {SyncJsonValue} */ (mutation.payload || null)
167
+ },
168
+ serverModel: serverRecord ? serverRecord.attributes : null,
169
+ serverVersion: serverRecord ? /** @type {SyncJsonValue} */ (serverRecord.version ?? serverRecord.attributes[versionAttribute] ?? null) : null,
170
+ suggestedResolution: strategy === "serverWins" ? "keep_server" : "manual",
171
+ versionAttribute
172
+ },
173
+ status: "conflict",
174
+ strategy
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Normalizes a custom resource conflict result.
180
+ * @param {unknown} result - Raw result.
181
+ * @param {string} strategy - Requested strategy.
182
+ * @returns {SyncConflictResult} - Normalized result.
183
+ */
184
+ function normalizeConflictResult(result, strategy) {
185
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Sync conflict handler must return an object")
186
+ const resultRecord = /** @type {Record<string, unknown>} */ (result)
187
+ if (!["applied", "conflict", "rejected"].includes(String(resultRecord.status))) throw new Error("Sync conflict handler returned an unknown status")
188
+
189
+ return /** @type {SyncConflictResult} */ ({strategy, ...resultRecord})
190
+ }
191
+
192
+ /**
193
+ * Returns mutation attributes as a JSON object.
194
+ * @param {import("./device-identity.js").SyncMutation} mutation - Mutation.
195
+ * @returns {Record<string, SyncJsonValue>} - Attributes.
196
+ */
197
+ function mutationAttributesFor(mutation) {
198
+ if (!mutation.attributes || typeof mutation.attributes !== "object" || Array.isArray(mutation.attributes)) return {}
199
+
200
+ return /** @type {Record<string, SyncJsonValue>} */ (JSON.parse(JSON.stringify(mutation.attributes)))
201
+ }
202
+
203
+ /**
204
+ * Compares JSON values by stable serialization.
205
+ * @param {unknown} left - Left value.
206
+ * @param {unknown} right - Right value.
207
+ * @returns {boolean} - Whether values match.
208
+ */
209
+ function jsonValuesEqual(left, right) {
210
+ return stableJsonStringify(left) === stableJsonStringify(right)
211
+ }
212
+
213
+ /**
214
+ * Stable JSON stringify helper.
215
+ * @param {unknown} value - Value.
216
+ * @returns {string} - Stable JSON.
217
+ */
218
+ function stableJsonStringify(value) {
219
+ if (Array.isArray(value)) return `[${value.map((entry) => stableJsonStringify(entry)).join(",")}]`
220
+ if (value && typeof value === "object") {
221
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJsonStringify(/** @type {Record<string, unknown>} */ (value)[key])}`).join(",")}}`
222
+ }
223
+
224
+ return JSON.stringify(value)
225
+ }
@@ -0,0 +1,232 @@
1
+ // @ts-check
2
+
3
+ import {createSignedMutation, mutationIdempotencyKey, verifySignedMutation} from "./device-identity.js"
4
+
5
+ const PEER_MUTATION_BUNDLE_FORMAT = "velocious.sync.peer-mutation-bundle.v1"
6
+ const EXPORTABLE_STATUSES = new Set(["pending", "applied-locally", "conflict"])
7
+
8
+ /**
9
+ * Peer mutation bundle exported by a device for offline/P2P transfer.
10
+ * @typedef {object} PeerMutationBundle
11
+ * @property {string} exportedAt - ISO timestamp when the bundle was exported.
12
+ * @property {"velocious.sync.peer-mutation-bundle.v1"} format - Bundle format identifier.
13
+ * @property {PeerMutationBundleEntry[]} mutations - Signed mutations in local sequence order.
14
+ */
15
+
16
+ /**
17
+ * One peer mutation bundle entry.
18
+ * @typedef {object} PeerMutationBundleEntry
19
+ * @property {string} [localRecordId] - Exporting device's local mutation record id.
20
+ * @property {number} [localSequence] - Exporting device's local mutation sequence.
21
+ * @property {import("./device-identity.js").SignedSyncMutation} signedMutation - Device-signed mutation envelope.
22
+ */
23
+
24
+ /**
25
+ * Exports local non-terminal mutations as a signed peer-transfer bundle.
26
+ * @param {object} args - Arguments.
27
+ * @param {import("./device-identity.js").DeviceCertificate} args.deviceCertificate - Device certificate for signing records.
28
+ * @param {import("./device-identity.js").SyncJsonWebKey} args.devicePrivateKey - Device private key for signing records.
29
+ * @param {import("./local-mutation-log.js").default} args.mutationLog - Local mutation log.
30
+ * @param {() => Date} [args.now] - Export clock.
31
+ * @param {import("./local-mutation-log.js").LocalMutationStatus[]} [args.statuses] - Statuses to export.
32
+ * @returns {Promise<PeerMutationBundle>} - Signed peer mutation bundle.
33
+ */
34
+ export async function exportPeerMutationBundle({deviceCertificate, devicePrivateKey, mutationLog, now = () => new Date(), statuses}) {
35
+ const selectedStatuses = normalizeExportStatuses(statuses)
36
+ const timestamp = isoTimestamp(now(), "exportedAt")
37
+ const records = (await mutationLog.records())
38
+ .filter((record) => selectedStatuses.has(record.status))
39
+ .sort((left, right) => left.sequence - right.sequence)
40
+ const mutations = []
41
+
42
+ for (const record of records) {
43
+ mutations.push({
44
+ localRecordId: record.id,
45
+ localSequence: record.sequence,
46
+ signedMutation: await createSignedMutation({
47
+ deviceCertificate,
48
+ devicePrivateKey,
49
+ mutation: record.mutation
50
+ })
51
+ })
52
+ }
53
+
54
+ return {
55
+ exportedAt: timestamp,
56
+ format: PEER_MUTATION_BUNDLE_FORMAT,
57
+ mutations
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Imports verified peer mutations into the local mutation log.
63
+ * @param {object} args - Arguments.
64
+ * @param {import("./device-identity.js").SyncJsonWebKey} args.backendPublicKey - Backend public key for verifying peer certificates.
65
+ * @param {PeerMutationBundle} args.bundle - Peer bundle to import.
66
+ * @param {import("./local-mutation-log.js").default} args.mutationLog - Local mutation log.
67
+ * @param {Date} [args.now] - Verification time.
68
+ * @returns {Promise<{imported: {clientMutationId: string, idempotencyKey: string, localRecordId: string}[], rejected: {errorMessage: string, index: number}[], skipped: {clientMutationId: string, idempotencyKey: string, localRecordId: string, reason: "duplicate"}[]}>} - Import result.
69
+ */
70
+ export async function importPeerMutationBundle({backendPublicKey, bundle, mutationLog, now = new Date()}) {
71
+ const normalizedBundle = normalizePeerMutationBundle(bundle)
72
+ const existingRecords = await mutationLog.records()
73
+ const existingByIdempotencyKey = new Map(existingRecords.map((record) => [mutationIdempotencyKey({mutation: record.mutation}), record]))
74
+ /** @type {{clientMutationId: string, idempotencyKey: string, localRecordId: string}[]} */
75
+ const imported = []
76
+ /** @type {{errorMessage: string, index: number}[]} */
77
+ const rejected = []
78
+ /** @type {{clientMutationId: string, idempotencyKey: string, localRecordId: string, reason: "duplicate"}[]} */
79
+ const skipped = []
80
+
81
+ for (const [index, entry] of normalizedBundle.mutations.entries()) {
82
+ const mutation = await verifiedBundleMutation({backendPublicKey, entry, index, now, rejected})
83
+
84
+ if (!mutation) continue
85
+
86
+ const idempotencyKey = mutationIdempotencyKey(entry.signedMutation)
87
+ const duplicateRecord = existingByIdempotencyKey.get(idempotencyKey)
88
+
89
+ if (duplicateRecord) {
90
+ skipped.push({
91
+ clientMutationId: mutation.clientMutationId,
92
+ idempotencyKey,
93
+ localRecordId: duplicateRecord.id,
94
+ reason: /** @type {"duplicate"} */ ("duplicate")
95
+ })
96
+ continue
97
+ }
98
+
99
+ const record = await mutationLog.append({mutation})
100
+ const updated = await mutationLog.updateStatus({id: record.id, status: "peer-applied"})
101
+ existingByIdempotencyKey.set(idempotencyKey, updated)
102
+ imported.push({clientMutationId: mutation.clientMutationId, idempotencyKey, localRecordId: updated.id})
103
+ }
104
+
105
+ return {imported, rejected, skipped}
106
+ }
107
+
108
+ /**
109
+ * Verifies a bundle mutation and records normal per-entry verification rejections.
110
+ * Storage writes intentionally happen outside this helper so storage failures escape import.
111
+ * @param {object} args - Arguments.
112
+ * @param {import("./device-identity.js").SyncJsonWebKey} args.backendPublicKey - Backend public key.
113
+ * @param {PeerMutationBundleEntry} args.entry - Bundle entry.
114
+ * @param {number} args.index - Entry index.
115
+ * @param {Date} args.now - Verification time.
116
+ * @param {{errorMessage: string, index: number}[]} args.rejected - Rejection accumulator.
117
+ * @returns {Promise<import("./device-identity.js").SyncMutation | null>} - Verified mutation, or null when rejected.
118
+ */
119
+ async function verifiedBundleMutation({backendPublicKey, entry, index, now, rejected}) {
120
+ try {
121
+ return await verifySignedMutation({backendPublicKey, now, signedMutation: entry.signedMutation})
122
+ } catch (error) {
123
+ rejected.push({errorMessage: errorMessage(error), index})
124
+
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Normalizes export status filters.
131
+ * @param {import("./local-mutation-log.js").LocalMutationStatus[] | undefined} statuses - Optional statuses.
132
+ * @returns {Set<import("./local-mutation-log.js").LocalMutationStatus>} - Status set.
133
+ */
134
+ function normalizeExportStatuses(statuses) {
135
+ if (statuses === undefined) return /** @type {Set<import("./local-mutation-log.js").LocalMutationStatus>} */ (new Set(EXPORTABLE_STATUSES))
136
+ if (!Array.isArray(statuses)) throw new Error("Expected peer mutation export statuses array")
137
+
138
+ const normalized = new Set()
139
+
140
+ for (const status of statuses) {
141
+ if (!EXPORTABLE_STATUSES.has(status)) throw new Error(`Unsupported peer mutation export status '${String(status)}'`)
142
+ normalized.add(status)
143
+ }
144
+
145
+ return /** @type {Set<import("./local-mutation-log.js").LocalMutationStatus>} */ (normalized)
146
+ }
147
+
148
+ /**
149
+ * Normalizes a peer mutation bundle.
150
+ * @param {PeerMutationBundle} bundle - Bundle value.
151
+ * @returns {PeerMutationBundle} - Normalized bundle.
152
+ */
153
+ function normalizePeerMutationBundle(bundle) {
154
+ if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) throw new Error("Expected peer mutation bundle object")
155
+ if (bundle.format !== PEER_MUTATION_BUNDLE_FORMAT) throw new Error(`Unsupported peer mutation bundle format '${String(bundle.format)}'`)
156
+ isoTimestamp(new Date(requiredString(bundle.exportedAt, "exportedAt")), "exportedAt")
157
+ if (!Array.isArray(bundle.mutations)) throw new Error("Expected peer mutation bundle mutations array")
158
+
159
+ return {
160
+ exportedAt: bundle.exportedAt,
161
+ format: bundle.format,
162
+ mutations: bundle.mutations.map((entry, index) => normalizePeerMutationBundleEntry(entry, index))
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Normalizes one peer mutation bundle entry.
168
+ * @param {PeerMutationBundleEntry} entry - Bundle entry.
169
+ * @param {number} index - Entry index.
170
+ * @returns {PeerMutationBundleEntry} - Normalized entry.
171
+ */
172
+ function normalizePeerMutationBundleEntry(entry, index) {
173
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`Expected peer mutation bundle entry ${index} object`)
174
+ if (!entry.signedMutation || typeof entry.signedMutation !== "object" || Array.isArray(entry.signedMutation)) throw new Error(`Expected peer mutation bundle entry ${index} signedMutation object`)
175
+
176
+ /** @type {PeerMutationBundleEntry} */
177
+ const normalized = {
178
+ signedMutation: entry.signedMutation
179
+ }
180
+
181
+ if (entry.localRecordId !== undefined) normalized.localRecordId = requiredString(entry.localRecordId, `entry ${index} localRecordId`)
182
+ if (entry.localSequence !== undefined) normalized.localSequence = positiveInteger(entry.localSequence, `entry ${index} localSequence`)
183
+
184
+ return normalized
185
+ }
186
+
187
+ /**
188
+ * Requires a non-empty string.
189
+ * @param {unknown} value - Value.
190
+ * @param {string} label - Label.
191
+ * @returns {string} - String.
192
+ */
193
+ function requiredString(value, label) {
194
+ if (typeof value !== "string" || value.length < 1) throw new Error(`Expected peer mutation bundle ${label}`)
195
+
196
+ return value
197
+ }
198
+
199
+ /**
200
+ * Requires a positive integer.
201
+ * @param {unknown} value - Value.
202
+ * @param {string} label - Label.
203
+ * @returns {number} - Integer.
204
+ */
205
+ function positiveInteger(value, label) {
206
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error(`Expected peer mutation bundle ${label} positive integer`)
207
+
208
+ return value
209
+ }
210
+
211
+ /**
212
+ * Returns an ISO timestamp from a valid Date.
213
+ * @param {Date} date - Date.
214
+ * @param {string} label - Label.
215
+ * @returns {string} - ISO timestamp.
216
+ */
217
+ function isoTimestamp(date, label) {
218
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new Error(`Invalid peer mutation bundle ${label}`)
219
+
220
+ return date.toISOString()
221
+ }
222
+
223
+ /**
224
+ * Returns a safe error message.
225
+ * @param {unknown} error - Error.
226
+ * @returns {string} - Message.
227
+ */
228
+ function errorMessage(error) {
229
+ if (error instanceof Error) return error.message
230
+
231
+ return String(error)
232
+ }