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
@@ -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.