velocious 1.0.476 → 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.
Files changed (50) hide show
  1. package/build/configuration-types.js +3 -0
  2. package/build/configuration.js +24 -0
  3. package/build/frontend-model-controller.js +368 -11
  4. package/build/frontend-models/base.js +5 -1
  5. package/build/frontend-models/resource-definition.js +20 -3
  6. package/build/http-server/client/websocket-session.js +93 -0
  7. package/build/routes/hooks/frontend-model-command-route-hook.js +16 -0
  8. package/build/src/configuration-types.d.ts +15 -0
  9. package/build/src/configuration-types.d.ts.map +1 -1
  10. package/build/src/configuration-types.js +4 -1
  11. package/build/src/configuration.d.ts +13 -0
  12. package/build/src/configuration.d.ts.map +1 -1
  13. package/build/src/configuration.js +23 -1
  14. package/build/src/frontend-model-controller.d.ts +96 -2
  15. package/build/src/frontend-model-controller.d.ts.map +1 -1
  16. package/build/src/frontend-model-controller.js +319 -12
  17. package/build/src/frontend-models/base.d.ts +5 -0
  18. package/build/src/frontend-models/base.d.ts.map +1 -1
  19. package/build/src/frontend-models/base.js +6 -2
  20. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  21. package/build/src/frontend-models/resource-definition.js +20 -4
  22. package/build/src/http-server/client/websocket-session.d.ts +39 -0
  23. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  24. package/build/src/http-server/client/websocket-session.js +85 -1
  25. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  26. package/build/src/routes/hooks/frontend-model-command-route-hook.js +15 -1
  27. package/build/src/sync/conflict-strategy.d.ts +83 -0
  28. package/build/src/sync/conflict-strategy.d.ts.map +1 -0
  29. package/build/src/sync/conflict-strategy.js +215 -0
  30. package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
  31. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
  32. package/build/src/sync/peer-mutation-bundle.js +215 -0
  33. package/build/src/sync/server-change-feed.d.ts +265 -0
  34. package/build/src/sync/server-change-feed.d.ts.map +1 -0
  35. package/build/src/sync/server-change-feed.js +475 -0
  36. package/build/sync/conflict-strategy.js +225 -0
  37. package/build/sync/peer-mutation-bundle.js +232 -0
  38. package/build/sync/server-change-feed.js +524 -0
  39. package/build/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +1 -1
  41. package/src/configuration-types.js +3 -0
  42. package/src/configuration.js +24 -0
  43. package/src/frontend-model-controller.js +368 -11
  44. package/src/frontend-models/base.js +5 -1
  45. package/src/frontend-models/resource-definition.js +20 -3
  46. package/src/http-server/client/websocket-session.js +93 -0
  47. package/src/routes/hooks/frontend-model-command-route-hook.js +16 -0
  48. package/src/sync/conflict-strategy.js +225 -0
  49. package/src/sync/peer-mutation-bundle.js +232 -0
  50. package/src/sync/server-change-feed.js +524 -0
@@ -348,6 +348,7 @@
348
348
  * frontends and peers; `policy` is hashed but intentionally omitted from
349
349
  * frontend-safe manifests.
350
350
  * @typedef {object} FrontendModelResourceSyncConfiguration
351
+ * @property {"optimisticVersion" | "serverWins" | "lastWriterWins" | "fieldThreeWay" | "appendOnly"} [conflictStrategy] - Strategy used when replay detects server/client divergence. Defaults to `optimisticVersion`.
351
352
  * @property {boolean} [enabled] - Whether the resource is sync-enabled. Defaults to true when `sync` is configured.
352
353
  * @property {string[]} [operations] - Sync operation names such as `index`, `find`, `create`, `update`, custom domain commands, etc.
353
354
  * @property {string | number} [policyVersion] - App-controlled policy version used as a stable change signal.
@@ -359,6 +360,7 @@
359
360
  * Velocious sync configuration.
360
361
  * @typedef {object} VelociousSyncConfiguration
361
362
  * @property {import("./sync/device-identity.js").SyncJsonWebKey | null} [deviceCertificateBackendPublicKey] - Public backend key used to verify offline device certificates for sync replay.
363
+ * @property {number} [changeFeedRetentionSize] - Number of accepted server changes retained before clients must refresh from snapshot.
362
364
  * @property {Array<import("./sync/offline-grant.js").OfflineGrantSigningKey>} offlineGrantSigningKeys - Signing keys used to issue and verify offline grants. Secrets must never be exposed to clients.
363
365
  * @property {number} [offlineGrantTtlMs] - Default offline grant TTL in milliseconds. Defaults to 24 hours.
364
366
  */
@@ -366,6 +368,7 @@
366
368
  /**
367
369
  * Frontend-safe normalized sync metadata.
368
370
  * @typedef {object} NormalizedFrontendModelResourceSyncConfiguration
371
+ * @property {"optimisticVersion" | "serverWins" | "lastWriterWins" | "fieldThreeWay" | "appendOnly"} conflictStrategy - Normalized replay conflict strategy.
369
372
  * @property {boolean} enabled - Whether the resource is sync-enabled.
370
373
  * @property {string[]} operations - Sorted, duplicate-free sync operation names.
371
374
  * @property {string | null} policyVersion - App-controlled policy version, or null.
@@ -232,6 +232,9 @@ export default class VelociousConfiguration {
232
232
  /** Grace period for paused WebSocket sessions before permanent teardown. */
233
233
  this._websocketSessionGraceSeconds = 300
234
234
 
235
+ /** Interval (seconds) between server→client heartbeat pings; 0 disables reaping of silent sockets. */
236
+ this._websocketSessionHeartbeatSeconds = 30
237
+
235
238
  /**
236
239
  * Optional wrapper called around every WebSocket-borne request /
237
240
  * connection message / channel dispatch. Apps register it here
@@ -407,18 +410,23 @@ export default class VelociousConfiguration {
407
410
  */
408
411
  _normalizeSyncConfiguration(sync) {
409
412
  const deviceCertificateBackendPublicKey = sync?.deviceCertificateBackendPublicKey || null
413
+ const changeFeedRetentionSize = sync?.changeFeedRetentionSize
410
414
  const offlineGrantSigningKeys = sync?.offlineGrantSigningKeys || []
411
415
  const offlineGrantTtlMs = sync?.offlineGrantTtlMs
412
416
 
413
417
  if (deviceCertificateBackendPublicKey !== null && (typeof deviceCertificateBackendPublicKey !== "object" || Array.isArray(deviceCertificateBackendPublicKey))) {
414
418
  throw new Error("sync.deviceCertificateBackendPublicKey must be a public JSON Web Key object")
415
419
  }
420
+ if (changeFeedRetentionSize !== undefined && (!Number.isInteger(changeFeedRetentionSize) || changeFeedRetentionSize <= 0)) {
421
+ throw new Error("sync.changeFeedRetentionSize must be a positive integer")
422
+ }
416
423
  if (!Array.isArray(offlineGrantSigningKeys)) throw new Error("sync.offlineGrantSigningKeys must be an array")
417
424
  if (offlineGrantTtlMs !== undefined && (!Number.isInteger(offlineGrantTtlMs) || offlineGrantTtlMs <= 0)) {
418
425
  throw new Error("sync.offlineGrantTtlMs must be a positive integer number of milliseconds")
419
426
  }
420
427
 
421
428
  return {
429
+ changeFeedRetentionSize: changeFeedRetentionSize || 10000,
422
430
  deviceCertificateBackendPublicKey,
423
431
  offlineGrantSigningKeys: offlineGrantSigningKeys.map((key) => normalizeOfflineGrantSigningKey(key)),
424
432
  offlineGrantTtlMs: offlineGrantTtlMs || 24 * 60 * 60 * 1000
@@ -2047,6 +2055,12 @@ export default class VelociousConfiguration {
2047
2055
  */
2048
2056
  getWebsocketSessionGraceSeconds() { return this._websocketSessionGraceSeconds }
2049
2057
 
2058
+ /**
2059
+ * Runs get websocket session heartbeat seconds.
2060
+ * @returns {number} - Interval (seconds) between server→client heartbeat pings; 0 disables reaping.
2061
+ */
2062
+ getWebsocketSessionHeartbeatSeconds() { return this._websocketSessionHeartbeatSeconds }
2063
+
2050
2064
  /**
2051
2065
  * Registers a wrapper invoked around every WS-borne request /
2052
2066
  * connection message / channel dispatch. The wrapper receives the
@@ -2124,6 +2138,16 @@ export default class VelociousConfiguration {
2124
2138
  this._websocketSessionGraceSeconds = seconds
2125
2139
  }
2126
2140
 
2141
+ /**
2142
+ * Runs set websocket session heartbeat seconds.
2143
+ * @param {number} seconds
2144
+ * @returns {void}
2145
+ */
2146
+ setWebsocketSessionHeartbeatSeconds(seconds) {
2147
+ if (!Number.isFinite(seconds) || seconds < 0) throw new Error(`Invalid heartbeat seconds: ${seconds}`)
2148
+ this._websocketSessionHeartbeatSeconds = seconds
2149
+ }
2150
+
2127
2151
  /**
2128
2152
  * Moves a session into the paused registry and starts the grace
2129
2153
  * timer. When the timer fires, the session's permanent teardown
@@ -7,6 +7,7 @@ import Response from "./http-server/client/response.js"
7
7
  import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
8
8
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject, frontendModelSyncManifestForBackendProjects} from "./frontend-models/resource-definition.js"
9
9
  import {createOfflineGrantFromBootstrap, verifyOfflineGrant} from "./sync/offline-grant.js"
10
+ import {serverChangeFeedStoreForConfiguration} from "./sync/server-change-feed.js"
10
11
  import {mutationIdempotencyKey, verifySignedMutation} from "./sync/device-identity.js"
11
12
  import {FrontendModelQueryError, normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
12
13
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
@@ -3594,9 +3595,16 @@ export default class FrontendModelController extends Controller {
3594
3595
 
3595
3596
  try {
3596
3597
  idempotencyKey = mutationIdempotencyKey(/** @type {import("./sync/device-identity.js").SignedSyncMutation} */ (signedMutation))
3597
- const response = await this.frontendSyncReplaySignedMutation(signedMutation)
3598
+ const {response, serverChangeFeedError, serverChangeFeedStatus, serverSequence} = await this.frontendSyncReplaySignedMutation(signedMutation)
3598
3599
 
3599
- results.push({idempotencyKey, response, status: "success"})
3600
+ results.push({
3601
+ idempotencyKey,
3602
+ response,
3603
+ serverChangeFeedError,
3604
+ serverChangeFeedStatus,
3605
+ serverSequence,
3606
+ status: "success"
3607
+ })
3600
3608
  } catch (error) {
3601
3609
  const errorContext = this.frontendModelEndpointErrorContext({
3602
3610
  action: "frontendSyncReplay",
@@ -3647,7 +3655,7 @@ export default class FrontendModelController extends Controller {
3647
3655
  /**
3648
3656
  * Verifies and replays one signed sync mutation.
3649
3657
  * @param {?} signedMutation - Signed mutation envelope.
3650
- * @returns {Promise<Record<string, ?>>} - Frontend-model command response.
3658
+ * @returns {Promise<{response: Record<string, ?>, serverChangeFeedError?: Record<string, ?>, serverChangeFeedStatus?: "error", serverSequence: number | null}>} - Frontend-model command response and appended server sequence.
3651
3659
  */
3652
3660
  async frontendSyncReplaySignedMutation(signedMutation) {
3653
3661
  const configuration = this.getConfiguration()
@@ -3677,9 +3685,6 @@ export default class FrontendModelController extends Controller {
3677
3685
  if (syncResource.policyHash !== mutation.policyHash) {
3678
3686
  throw frontendSyncReplaySafeError(`Sync replay policy hash mismatch for ${mutation.model}`)
3679
3687
  }
3680
- if (!["create", "update", "destroy"].includes(mutation.operation)) {
3681
- throw frontendSyncReplaySafeError(`Sync replay operation is not supported yet: ${mutation.operation}`)
3682
- }
3683
3688
 
3684
3689
  const signedOfflineGrant = this.frontendSyncReplaySignedOfflineGrant(signedMutation)
3685
3690
  const offlineGrant = await this.frontendSyncReplayVerifiedOfflineGrant({
@@ -3690,17 +3695,24 @@ export default class FrontendModelController extends Controller {
3690
3695
  this.frontendSyncReplayValidateOfflineGrant({mutation, offlineGrant, syncResource})
3691
3696
 
3692
3697
  const commandParams = await this.frontendSyncReplayCommandParams(mutation)
3698
+ const replayCommand = this.frontendSyncReplayCommandForMutation(mutation)
3699
+
3700
+ let response
3693
3701
 
3694
3702
  try {
3695
- return await this.withFrontendModelParams(commandParams, async () => {
3703
+ response = await this.withFrontendModelParams(commandParams, async () => {
3696
3704
  return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
3697
- return await this.frontendModelCommandPayload(/** @type {"create" | "update" | "destroy"} */ (mutation.operation)) || this.frontendModelErrorPayload("Action halted by beforeAction.")
3705
+ if (["create", "update", "destroy"].includes(mutation.operation)) {
3706
+ return await this.frontendModelCommandPayload(/** @type {"create" | "update" | "destroy"} */ (mutation.operation)) || this.frontendModelErrorPayload("Action halted by beforeAction.")
3707
+ }
3708
+
3709
+ return await this.frontendSyncReplayCustomCommandPayload({mutation, replayCommand}) || this.frontendModelErrorPayload("Action halted by beforeAction.")
3698
3710
  })
3699
3711
  })
3700
3712
  } catch (error) {
3701
3713
  const errorContext = this.frontendModelEndpointErrorContext({
3702
3714
  action: "frontendSyncReplay",
3703
- commandType: /** @type {"create" | "update" | "destroy"} */ (mutation.operation),
3715
+ commandType: /** @type {?} */ (replayCommand.commandType),
3704
3716
  error,
3705
3717
  model: mutation.model
3706
3718
  })
@@ -3712,7 +3724,42 @@ export default class FrontendModelController extends Controller {
3712
3724
  model: errorContext.model
3713
3725
  })
3714
3726
 
3715
- return await this.frontendModelClientErrorPayloadForError(error, errorContext)
3727
+ return {
3728
+ response: await this.frontendModelClientErrorPayloadForError(error, errorContext),
3729
+ serverSequence: null
3730
+ }
3731
+ }
3732
+
3733
+ try {
3734
+ const serverSequence = await this.frontendSyncAppendServerChange({
3735
+ idempotencyKey: mutationIdempotencyKey(/** @type {import("./sync/device-identity.js").SignedSyncMutation} */ (signedMutation)),
3736
+ mutation,
3737
+ offlineGrant,
3738
+ response
3739
+ })
3740
+
3741
+ return {response, serverSequence}
3742
+ } catch (error) {
3743
+ const errorContext = this.frontendModelEndpointErrorContext({
3744
+ action: "frontendSyncReplay",
3745
+ commandType: /** @type {?} */ (replayCommand.commandType),
3746
+ error,
3747
+ model: mutation.model
3748
+ })
3749
+
3750
+ await this.frontendModelLogEndpointError({
3751
+ action: errorContext.action,
3752
+ commandType: errorContext.commandType,
3753
+ error,
3754
+ model: errorContext.model
3755
+ })
3756
+
3757
+ return {
3758
+ response,
3759
+ serverChangeFeedError: await this.frontendModelClientErrorPayloadForError(error, errorContext),
3760
+ serverChangeFeedStatus: "error",
3761
+ serverSequence: null
3762
+ }
3716
3763
  }
3717
3764
  }
3718
3765
 
@@ -3788,6 +3835,60 @@ export default class FrontendModelController extends Controller {
3788
3835
  }
3789
3836
  }
3790
3837
 
3838
+ /**
3839
+ * Replays a verified custom sync mutation through the resource command API.
3840
+ * @param {object} args - Arguments.
3841
+ * @param {import("./sync/device-identity.js").SyncMutation} args.mutation - Verified mutation.
3842
+ * @param {{commandType: string, methodName?: string, scope?: "collection" | "member"}} args.replayCommand - Resolved replay command metadata.
3843
+ * @returns {Promise<Record<string, ?>>} - Command response payload.
3844
+ */
3845
+ async frontendSyncReplayCustomCommandPayload({mutation, replayCommand}) {
3846
+ if (typeof replayCommand.methodName !== "string" || replayCommand.methodName.length < 1) {
3847
+ throw frontendSyncReplaySafeError(`Sync replay command is not registered for ${mutation.model}: ${mutation.operation}`)
3848
+ }
3849
+
3850
+ const frontendModelResource = this.getConfiguration().getBackendProjects()
3851
+ .map((backendProject) => this.frontendModelResourceConfigurationForBackendProjectModelName({backendProject, modelName: mutation.model}))
3852
+ .find((resourceConfiguration) => resourceConfiguration)
3853
+
3854
+ if (!frontendModelResource) throw frontendSyncReplaySafeError(`Sync replay model is not enabled: ${mutation.model}`)
3855
+
3856
+ const resource = new frontendModelResource.resourceClass({
3857
+ ability: this.currentAbility(),
3858
+ controller: this,
3859
+ context: {
3860
+ ...(this.currentAbility()?.getContext() || {}),
3861
+ params: this.frontendModelParams(),
3862
+ request: this.request()
3863
+ },
3864
+ locals: this.currentAbility()?.getLocals() || {},
3865
+ modelClass: this.frontendModelResourceModelClass(frontendModelResource),
3866
+ modelName: frontendModelResource.modelName,
3867
+ params: this.frontendModelParams(),
3868
+ resourceConfiguration: frontendModelResource.resourceConfiguration
3869
+ })
3870
+ const command = resource.resourceMethod(replayCommand.methodName)
3871
+
3872
+ if (!command) {
3873
+ return this.frontendModelErrorPayload(`Missing frontend-model custom command '${replayCommand.methodName}'.`)
3874
+ }
3875
+
3876
+ const commandArguments = /** @type {Record<string, ?>} */ (mutation.payload && typeof mutation.payload === "object" && !Array.isArray(mutation.payload) ? mutation.payload : {})
3877
+ const responsePayload = await command.method.call(command.resource, commandArguments)
3878
+
3879
+ if (!responsePayload || typeof responsePayload !== "object") {
3880
+ return {status: "success"}
3881
+ }
3882
+
3883
+ return /** @type {Record<string, ?>} */ (
3884
+ await this.autoSerializeFrontendModelsInPayload(
3885
+ responsePayload,
3886
+ /** @type {{serialize: (model: ?, action: string) => Promise<Record<string, ?>>}} */ (command.resource),
3887
+ replayCommand.methodName
3888
+ )
3889
+ )
3890
+ }
3891
+
3791
3892
  /**
3792
3893
  * Builds frontend-model command params for a verified replay mutation.
3793
3894
  * @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
@@ -3802,7 +3903,24 @@ export default class FrontendModelController extends Controller {
3802
3903
  model: mutation.model
3803
3904
  })
3804
3905
 
3805
- if (mutation.operation !== "create") {
3906
+ if (["create", "update", "destroy"].includes(mutation.operation)) {
3907
+ if (mutation.operation !== "create") {
3908
+ const id = commandParams.id || commandParams.recordId || primaryKeyValue
3909
+
3910
+ if (typeof id !== "string" && typeof id !== "number") throw frontendSyncReplaySafeError(`Sync replay ${mutation.operation} requires an id`)
3911
+
3912
+ commandParams.id = id
3913
+ }
3914
+
3915
+ return commandParams
3916
+ }
3917
+
3918
+ const replayCommand = this.frontendSyncReplayCommandForMutation(mutation)
3919
+
3920
+ commandParams.frontendModelCustomCommandMethodName = replayCommand.methodName
3921
+ commandParams.frontendModelCustomCommandScope = replayCommand.scope
3922
+
3923
+ if (replayCommand.scope === "member") {
3806
3924
  const id = commandParams.id || commandParams.recordId || primaryKeyValue
3807
3925
 
3808
3926
  if (typeof id !== "string" && typeof id !== "number") throw frontendSyncReplaySafeError(`Sync replay ${mutation.operation} requires an id`)
@@ -3813,6 +3931,36 @@ export default class FrontendModelController extends Controller {
3813
3931
  return commandParams
3814
3932
  }
3815
3933
 
3934
+ /**
3935
+ * Resolves the frontend-model command used for a verified replay mutation.
3936
+ * @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
3937
+ * @returns {{commandType: string, methodName?: string, scope?: "collection" | "member"}} - Command metadata.
3938
+ */
3939
+ frontendSyncReplayCommandForMutation(mutation) {
3940
+ if (["create", "update", "destroy"].includes(mutation.operation)) {
3941
+ return {commandType: mutation.operation}
3942
+ }
3943
+
3944
+ const frontendModelResource = this.getConfiguration().getBackendProjects()
3945
+ .map((backendProject) => this.frontendModelResourceConfigurationForBackendProjectModelName({backendProject, modelName: mutation.model}))
3946
+ .find((resourceConfiguration) => resourceConfiguration)
3947
+
3948
+ if (!frontendModelResource) throw frontendSyncReplaySafeError(`Sync replay model is not enabled: ${mutation.model}`)
3949
+
3950
+ const commandName = typeof mutation.command === "string" && mutation.command.length > 0 ? mutation.command : mutation.operation
3951
+ const resourceConfiguration = frontendModelResource.resourceConfiguration
3952
+
3953
+ if (Object.prototype.hasOwnProperty.call(resourceConfiguration.collectionCommands, commandName)) {
3954
+ return {commandType: commandName, methodName: commandName, scope: "collection"}
3955
+ }
3956
+
3957
+ if (Object.prototype.hasOwnProperty.call(resourceConfiguration.memberCommands, commandName)) {
3958
+ return {commandType: commandName, methodName: commandName, scope: "member"}
3959
+ }
3960
+
3961
+ throw frontendSyncReplaySafeError(`Sync replay command is not registered for ${mutation.model}: ${commandName}`)
3962
+ }
3963
+
3816
3964
  /**
3817
3965
  * Resolves command attributes and primary key from a replay mutation.
3818
3966
  * @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
@@ -3835,6 +3983,215 @@ export default class FrontendModelController extends Controller {
3835
3983
  return {attributes, primaryKeyValue}
3836
3984
  }
3837
3985
 
3986
+ /**
3987
+ * Appends a successfully replayed mutation to the server change feed.
3988
+ * @param {object} args - Arguments.
3989
+ * @param {string | null} args.idempotencyKey - Mutation idempotency key.
3990
+ * @param {import("./sync/device-identity.js").SyncMutation} args.mutation - Verified mutation.
3991
+ * @param {import("./sync/offline-grant.js").OfflineGrant} args.offlineGrant - Verified offline grant.
3992
+ * @param {Record<string, ?>} args.response - Replay command response.
3993
+ * @returns {Promise<number | null>} - Assigned server sequence, or null when no change was appended.
3994
+ */
3995
+ async frontendSyncAppendServerChange({idempotencyKey, mutation, offlineGrant, response}) {
3996
+ if (response.status !== "success") return null
3997
+
3998
+ const store = serverChangeFeedStoreForConfiguration(this.getConfiguration())
3999
+ const responseSyncChanges = Array.isArray(response.syncChanges) ? response.syncChanges : []
4000
+ const syncChanges = responseSyncChanges.length > 0 ? responseSyncChanges : [{
4001
+ attributes: mutation.attributes,
4002
+ model: mutation.model,
4003
+ operation: mutation.operation,
4004
+ payload: mutation.payload
4005
+ }]
4006
+ let serverSequence = /** @type {number | null} */ (null)
4007
+
4008
+ for (const syncChange of syncChanges) {
4009
+ if (!syncChange || typeof syncChange !== "object" || Array.isArray(syncChange)) continue
4010
+
4011
+ const change = /** @type {Record<string, ?>} */ (syncChange)
4012
+ const payload = /** @type {Record<string, ?>} */ (change.payload && typeof change.payload === "object" && !Array.isArray(change.payload) ? change.payload : {})
4013
+ const attributes = /** @type {Record<string, ?>} */ (change.attributes && typeof change.attributes === "object" && !Array.isArray(change.attributes) ? change.attributes : {})
4014
+ const model = typeof change.model === "string" && change.model.length > 0 ? change.model : mutation.model
4015
+ const operation = typeof change.operation === "string" && change.operation.length > 0 ? change.operation : mutation.operation
4016
+ const rawRecordId = change.recordId ?? payload.id ?? payload.recordId ?? attributes.id ?? null
4017
+ const recordId = rawRecordId === null || rawRecordId === undefined ? null : String(rawRecordId)
4018
+ const appendedChange = await store.append({
4019
+ actorDeviceId: mutation.actorDeviceId,
4020
+ actorUserId: mutation.actorUserId,
4021
+ attributes,
4022
+ idempotencyKey,
4023
+ model,
4024
+ operation,
4025
+ payload,
4026
+ recordId,
4027
+ response,
4028
+ scope: offlineGrant.scopes
4029
+ })
4030
+
4031
+ serverSequence = appendedChange.serverSequence
4032
+ }
4033
+
4034
+ return serverSequence
4035
+ }
4036
+
4037
+ /**
4038
+ * Verifies the signed offline grant used to scope sync read endpoints.
4039
+ * @param {Record<string, ?>} params - Request params.
4040
+ * @returns {Promise<import("./sync/offline-grant.js").OfflineGrant>} - Verified offline grant.
4041
+ */
4042
+ async frontendSyncRequestVerifiedOfflineGrant(params) {
4043
+ const signedOfflineGrant = this.frontendSyncReplaySignedOfflineGrant(params)
4044
+
4045
+ return await this.frontendSyncReplayVerifiedOfflineGrant({
4046
+ signedOfflineGrant,
4047
+ signingKeys: this.getConfiguration().getSyncConfiguration().offlineGrantSigningKeys
4048
+ })
4049
+ }
4050
+
4051
+ /**
4052
+ * Runs frontend sync change feed.
4053
+ * @returns {Promise<void>} - Sync change-feed response.
4054
+ */
4055
+ async frontendSyncChangeFeed() {
4056
+ if (this.request().httpMethod() === "OPTIONS") {
4057
+ await this.render({status: 204, json: {}})
4058
+ return
4059
+ }
4060
+
4061
+ const params = /** @type {Record<string, ?>} */ (deserializeFrontendModelTransportValue(this.params()))
4062
+ const offlineGrant = await this.frontendSyncRequestVerifiedOfflineGrant(params)
4063
+ const afterSequence = this.frontendSyncChangeFeedAfterSequence(params)
4064
+ const store = serverChangeFeedStoreForConfiguration(this.getConfiguration())
4065
+ const limit = this.frontendSyncChangeFeedLimit(params)
4066
+ const currentServerSequence = await store.latestSequence()
4067
+ const serverSequence = this.frontendSyncChangeFeedUpToSequence(params, currentServerSequence)
4068
+ const page = await store.changesAfter({afterSequence, limit, scope: offlineGrant.scopes, upToSequence: serverSequence})
4069
+
4070
+ if (page.snapshotRequired) {
4071
+ await this.render({
4072
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
4073
+ changes: [],
4074
+ oldestSequence: page.oldestSequence,
4075
+ requestedAfterSequence: afterSequence,
4076
+ serverSequence,
4077
+ snapshot: await this.frontendSyncSnapshotPayload({scope: offlineGrant.scopes, serverSequence}),
4078
+ status: "snapshot_required"
4079
+ }, this.transportSerializationOptions()))
4080
+ })
4081
+ return
4082
+ }
4083
+
4084
+ const changes = page.changes
4085
+ const includeSnapshot = params.snapshot === true || params.includeSnapshot === true || afterSequence === 0
4086
+ const snapshot = includeSnapshot ? await this.frontendSyncSnapshotPayload({scope: offlineGrant.scopes, serverSequence}) : undefined
4087
+
4088
+ const payload = /** @type {Record<string, ?>} */ ({
4089
+ changes,
4090
+ hasMore: page.hasMore,
4091
+ nextSequence: page.nextSequence,
4092
+ serverSequence,
4093
+ status: "success",
4094
+ upToSequence: page.upToSequence
4095
+ })
4096
+
4097
+ if (snapshot) payload.snapshot = snapshot
4098
+
4099
+ await this.render({
4100
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(payload, this.transportSerializationOptions()))
4101
+ })
4102
+ }
4103
+
4104
+ /**
4105
+ * Resolves sync change-feed cursor.
4106
+ * @param {Record<string, ?>} params - Request params.
4107
+ * @returns {number} - Exclusive lower-bound sequence.
4108
+ */
4109
+ frontendSyncChangeFeedAfterSequence(params) {
4110
+ const afterSequence = params.afterSequence ?? params.cursor ?? 0
4111
+
4112
+ if (typeof afterSequence === "number" && Number.isInteger(afterSequence) && afterSequence >= 0) return afterSequence
4113
+ if (typeof afterSequence === "string" && /^\d+$/.test(afterSequence)) return Number(afterSequence)
4114
+
4115
+ throw new Error("Expected sync change-feed afterSequence")
4116
+ }
4117
+
4118
+ /**
4119
+ * Resolves sync change-feed page limit.
4120
+ * @param {Record<string, ?>} params - Request params.
4121
+ * @returns {number} - Page limit.
4122
+ */
4123
+ frontendSyncChangeFeedLimit(params) {
4124
+ const limit = params.limit ?? params.pageSize ?? 100
4125
+
4126
+ if (typeof limit === "number" && Number.isInteger(limit) && limit > 0) return limit
4127
+ if (typeof limit === "string" && /^\d+$/.test(limit)) return Number(limit)
4128
+
4129
+ throw new Error("Expected sync change-feed positive limit")
4130
+ }
4131
+
4132
+ /**
4133
+ * Resolves sync change-feed stable high-water mark.
4134
+ * @param {Record<string, ?>} params - Request params.
4135
+ * @param {number} currentServerSequence - Current latest server sequence.
4136
+ * @returns {number} - Inclusive upper-bound sequence.
4137
+ */
4138
+ frontendSyncChangeFeedUpToSequence(params, currentServerSequence) {
4139
+ const upToSequence = params.upToSequence ?? params.serverSequence ?? currentServerSequence
4140
+
4141
+ if (typeof upToSequence === "number" && Number.isInteger(upToSequence) && upToSequence >= 0) return Math.min(upToSequence, currentServerSequence)
4142
+ if (typeof upToSequence === "string" && /^\d+$/.test(upToSequence)) return Math.min(Number(upToSequence), currentServerSequence)
4143
+
4144
+ throw new Error("Expected sync change-feed upToSequence")
4145
+ }
4146
+
4147
+ /**
4148
+ * Runs frontend sync snapshot endpoint.
4149
+ * @returns {Promise<void>} - Sync snapshot response.
4150
+ */
4151
+ async frontendSyncSnapshot() {
4152
+ if (this.request().httpMethod() === "OPTIONS") {
4153
+ await this.render({status: 204, json: {}})
4154
+ return
4155
+ }
4156
+
4157
+ const params = /** @type {Record<string, ?>} */ (deserializeFrontendModelTransportValue(this.params()))
4158
+ const offlineGrant = await this.frontendSyncRequestVerifiedOfflineGrant(params)
4159
+ const store = serverChangeFeedStoreForConfiguration(this.getConfiguration())
4160
+ const serverSequence = await store.latestSequence()
4161
+ const snapshot = await this.frontendSyncSnapshotPayload({scope: offlineGrant.scopes, serverSequence})
4162
+
4163
+ await this.render({
4164
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
4165
+ snapshot,
4166
+ status: "success"
4167
+ }, this.transportSerializationOptions()))
4168
+ })
4169
+ }
4170
+
4171
+ /**
4172
+ * Builds a snapshot of sync-enabled frontend model resources at a stable server sequence.
4173
+ * @param {object} args - Arguments.
4174
+ * @param {number} args.serverSequence - Snapshot sequence.
4175
+ * @param {Record<string, ?>} [args.scope] - Caller sync scope.
4176
+ * @returns {Promise<{resources: Record<string, ?>, serverSequence: number}>} - Snapshot payload.
4177
+ */
4178
+ async frontendSyncSnapshotPayload({scope, serverSequence}) {
4179
+ const syncManifest = frontendModelSyncManifestForBackendProjects(this.getConfiguration().getBackendProjects())
4180
+ const resources = /** @type {Record<string, ?>} */ ({})
4181
+
4182
+ for (const modelName of Object.keys(syncManifest).sort()) {
4183
+ const commandParams = {...(scope || {}), model: modelName}
4184
+
4185
+ resources[modelName] = await this.withFrontendModelParams(commandParams, async () => {
4186
+ return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
4187
+ return await this.frontendModelCommandPayload("index") || this.frontendModelErrorPayload("Action halted by beforeAction.")
4188
+ })
4189
+ })
4190
+ }
4191
+
4192
+ return {resources, serverSequence}
4193
+ }
4194
+
3838
4195
  /**
3839
4196
  * Runs frontend api.
3840
4197
  * @returns {Promise<void>} - Shared frontend model API action with batch support.
@@ -62,7 +62,11 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
62
62
  */
63
63
  /**
64
64
  * Defines this typedef.
65
- * @typedef {{enabled: boolean, operations: string[], policyHash: string, policyVersion: string | null, metadata?: FrontendModelSyncMetadata}} FrontendModelSyncConfig
65
+ * @typedef {"optimisticVersion" | "serverWins" | "lastWriterWins" | "fieldThreeWay" | "appendOnly"} FrontendModelSyncConflictStrategy
66
+ */
67
+ /**
68
+ * Defines this typedef.
69
+ * @typedef {{enabled: boolean, operations: string[], policyHash: string, policyVersion: string | null, conflictStrategy?: FrontendModelSyncConflictStrategy, metadata?: FrontendModelSyncMetadata}} FrontendModelSyncConfig
66
70
  */
67
71
  /**
68
72
  * Defines this typedef.
@@ -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.