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.
- package/README.md +1 -0
- package/build/configuration-types.js +3 -0
- package/build/configuration.js +24 -0
- package/build/frontend-model-controller.js +368 -11
- 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 +16 -0
- package/build/src/configuration-types.d.ts +15 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -1
- package/build/src/configuration.d.ts +13 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +23 -1
- package/build/src/frontend-model-controller.d.ts +96 -2
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +319 -12
- 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 +15 -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/src/sync/sync-envelope-replay-service.d.ts +213 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -0
- package/build/src/sync/sync-envelope-replay-service.js +229 -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/sync/sync-envelope-replay-service.js +263 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/configuration-types.js +3 -0
- package/src/configuration.js +24 -0
- package/src/frontend-model-controller.js +368 -11
- 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 +16 -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
- 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({
|
|
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,
|
|
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
|
-
|
|
3703
|
+
response = await this.withFrontendModelParams(commandParams, async () => {
|
|
3696
3704
|
return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
|
|
3697
|
-
|
|
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 {
|
|
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
|
|
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 (
|
|
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 {
|
|
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.
|