velocious 1.0.475 → 1.0.477
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/build/configuration-types.js +4 -0
- package/build/configuration.js +29 -0
- package/build/database/drivers/base.js +16 -0
- package/build/database/drivers/mssql/index.js +13 -0
- package/build/database/drivers/mssql/sql/remove-index.js +6 -0
- package/build/database/drivers/mysql/index.js +13 -0
- package/build/database/drivers/mysql/sql/remove-index.js +6 -0
- package/build/database/drivers/pgsql/index.js +13 -0
- package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/base.js +13 -0
- package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/build/database/migration/index.js +44 -0
- package/build/database/pool/base-methods-forward.js +1 -0
- package/build/database/query/remove-index-base.js +39 -0
- package/build/database/record/index.js +25 -9
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/build/frontend-model-controller.js +632 -1
- package/build/frontend-models/base.js +5 -1
- package/build/frontend-models/resource-definition.js +20 -3
- package/build/http-server/client/websocket-session.js +93 -0
- package/build/routes/hooks/frontend-model-command-route-hook.js +24 -0
- package/build/src/configuration-types.d.ts +20 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +5 -1
- package/build/src/configuration.d.ts +13 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +28 -1
- package/build/src/database/drivers/base.d.ts +20 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +16 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +12 -1
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +12 -1
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +12 -1
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +12 -1
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
- package/build/src/database/migration/index.d.ts +25 -0
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +39 -1
- package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
- package/build/src/database/pool/base-methods-forward.js +2 -1
- package/build/src/database/query/remove-index-base.d.ts +35 -0
- package/build/src/database/query/remove-index-base.d.ts.map +1 -0
- package/build/src/database/query/remove-index-base.js +34 -0
- package/build/src/database/record/index.d.ts +11 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +26 -11
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
- package/build/src/frontend-model-controller.d.ts +156 -0
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +555 -2
- package/build/src/frontend-models/base.d.ts +5 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +6 -2
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +20 -4
- package/build/src/http-server/client/websocket-session.d.ts +39 -0
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +85 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.js +22 -1
- package/build/src/sync/conflict-strategy.d.ts +83 -0
- package/build/src/sync/conflict-strategy.d.ts.map +1 -0
- package/build/src/sync/conflict-strategy.js +215 -0
- package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
- package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
- package/build/src/sync/peer-mutation-bundle.js +215 -0
- package/build/src/sync/server-change-feed.d.ts +265 -0
- package/build/src/sync/server-change-feed.d.ts.map +1 -0
- package/build/src/sync/server-change-feed.js +475 -0
- package/build/sync/conflict-strategy.js +225 -0
- package/build/sync/peer-mutation-bundle.js +232 -0
- package/build/sync/server-change-feed.js +524 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/configuration-types.js +4 -0
- package/src/configuration.js +29 -0
- package/src/database/drivers/base.js +16 -0
- package/src/database/drivers/mssql/index.js +13 -0
- package/src/database/drivers/mssql/sql/remove-index.js +6 -0
- package/src/database/drivers/mysql/index.js +13 -0
- package/src/database/drivers/mysql/sql/remove-index.js +6 -0
- package/src/database/drivers/pgsql/index.js +13 -0
- package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/base.js +13 -0
- package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/src/database/migration/index.js +44 -0
- package/src/database/pool/base-methods-forward.js +1 -0
- package/src/database/query/remove-index-base.js +39 -0
- package/src/database/record/index.js +25 -9
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/src/frontend-model-controller.js +632 -1
- package/src/frontend-models/base.js +5 -1
- package/src/frontend-models/resource-definition.js +20 -3
- package/src/http-server/client/websocket-session.js +93 -0
- package/src/routes/hooks/frontend-model-command-route-hook.js +24 -0
- package/src/sync/conflict-strategy.js +225 -0
- package/src/sync/peer-mutation-bundle.js +232 -0
- package/src/sync/server-change-feed.js +524 -0
|
@@ -288,7 +288,7 @@ function normalizeFrontendModelResourceSync(resourceConfiguration) {
|
|
|
288
288
|
const sync = resourceConfiguration.sync
|
|
289
289
|
|
|
290
290
|
if (sync === undefined || sync === null) return undefined
|
|
291
|
-
if (sync === false) return {enabled: false, operations: [], policyHash: syncPolicyHash({enabled: false}), policyVersion: null}
|
|
291
|
+
if (sync === false) return {conflictStrategy: "optimisticVersion", enabled: false, operations: [], policyHash: syncPolicyHash({conflictStrategy: "optimisticVersion", enabled: false}), policyVersion: null}
|
|
292
292
|
if (sync === true) {
|
|
293
293
|
return normalizeFrontendModelResourceSync({
|
|
294
294
|
...resourceConfiguration,
|
|
@@ -299,18 +299,20 @@ function normalizeFrontendModelResourceSync(resourceConfiguration) {
|
|
|
299
299
|
throw new Error("Resource sync configuration must be true, false, or an object.")
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
const {enabled = true, metadata, operations, policy, policyVersion, ...rest} = /** @type {import("../configuration-types.js").FrontendModelResourceSyncConfiguration} */ (sync)
|
|
302
|
+
const {conflictStrategy, enabled = true, metadata, operations, policy, policyVersion, ...rest} = /** @type {import("../configuration-types.js").FrontendModelResourceSyncConfiguration} */ (sync)
|
|
303
303
|
|
|
304
304
|
if (Object.keys(rest).length > 0) {
|
|
305
|
-
throw new Error(`Unexpected sync keys: ${Object.keys(rest).join(", ")}. Allowed: enabled, metadata, operations, policy, policyVersion`)
|
|
305
|
+
throw new Error(`Unexpected sync keys: ${Object.keys(rest).join(", ")}. Allowed: conflictStrategy, enabled, metadata, operations, policy, policyVersion`)
|
|
306
306
|
}
|
|
307
307
|
if (enabled !== true && enabled !== false) throw new Error("Resource sync enabled must be true or false when provided.")
|
|
308
308
|
|
|
309
|
+
const normalizedConflictStrategy = normalizeSyncConflictStrategy(conflictStrategy)
|
|
309
310
|
const normalizedOperations = normalizeSyncOperations(operations)
|
|
310
311
|
const normalizedMetadata = metadata === undefined ? undefined : deterministicSyncJson({label: "metadata", value: metadata})
|
|
311
312
|
const normalizedPolicy = policy === undefined ? undefined : deterministicSyncJson({label: "policy", value: policy})
|
|
312
313
|
const normalizedPolicyVersion = policyVersion === undefined || policyVersion === null ? null : String(policyVersion)
|
|
313
314
|
const hashInput = {
|
|
315
|
+
conflictStrategy: normalizedConflictStrategy,
|
|
314
316
|
enabled,
|
|
315
317
|
metadata: normalizedMetadata,
|
|
316
318
|
modelName: resourceConfiguration.modelName || null,
|
|
@@ -320,6 +322,7 @@ function normalizeFrontendModelResourceSync(resourceConfiguration) {
|
|
|
320
322
|
}
|
|
321
323
|
/** @type {import("../configuration-types.js").NormalizedFrontendModelResourceSyncConfiguration} */
|
|
322
324
|
const normalized = {
|
|
325
|
+
conflictStrategy: normalizedConflictStrategy,
|
|
323
326
|
enabled,
|
|
324
327
|
operations: normalizedOperations,
|
|
325
328
|
policyHash: syncPolicyHash(hashInput),
|
|
@@ -331,6 +334,20 @@ function normalizeFrontendModelResourceSync(resourceConfiguration) {
|
|
|
331
334
|
return normalized
|
|
332
335
|
}
|
|
333
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Normalizes the sync conflict strategy for replay clients/servers.
|
|
339
|
+
* @param {unknown} conflictStrategy - Raw strategy.
|
|
340
|
+
* @returns {"optimisticVersion" | "serverWins" | "lastWriterWins" | "fieldThreeWay" | "appendOnly"} - Normalized strategy.
|
|
341
|
+
*/
|
|
342
|
+
function normalizeSyncConflictStrategy(conflictStrategy) {
|
|
343
|
+
if (conflictStrategy === undefined || conflictStrategy === null) return "optimisticVersion"
|
|
344
|
+
if (["optimisticVersion", "serverWins", "lastWriterWins", "fieldThreeWay", "appendOnly"].includes(String(conflictStrategy))) {
|
|
345
|
+
return /** @type {"optimisticVersion" | "serverWins" | "lastWriterWins" | "fieldThreeWay" | "appendOnly"} */ (conflictStrategy)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
throw new Error(`Unknown resource sync conflictStrategy: ${String(conflictStrategy)}`)
|
|
349
|
+
}
|
|
350
|
+
|
|
334
351
|
/**
|
|
335
352
|
* Normalizes sync operations into a stable, duplicate-free list.
|
|
336
353
|
* @param {unknown} operations - Raw operations value.
|
|
@@ -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")
|
|
@@ -25,6 +25,30 @@ export default async function frontendModelCommandRouteHook({configuration, curr
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
if (normalizedCurrentPath === "/frontend-models/sync/replay") {
|
|
29
|
+
return {
|
|
30
|
+
action: "frontend-sync-replay",
|
|
31
|
+
controller: "velocious/api",
|
|
32
|
+
controllerPath: FRONTEND_MODEL_CONTROLLER_PATH
|
|
33
|
+
}
|
|
34
|
+
}
|
|
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
|
+
|
|
28
52
|
if (normalizedCurrentPath === SHARED_FRONTEND_MODEL_API_PATH) {
|
|
29
53
|
return {
|
|
30
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
|
+
}
|