velocious 1.0.475 → 1.0.477
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/build/configuration-types.js +4 -0
- package/build/configuration.js +29 -0
- package/build/database/drivers/base.js +16 -0
- package/build/database/drivers/mssql/index.js +13 -0
- package/build/database/drivers/mssql/sql/remove-index.js +6 -0
- package/build/database/drivers/mysql/index.js +13 -0
- package/build/database/drivers/mysql/sql/remove-index.js +6 -0
- package/build/database/drivers/pgsql/index.js +13 -0
- package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/base.js +13 -0
- package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/build/database/migration/index.js +44 -0
- package/build/database/pool/base-methods-forward.js +1 -0
- package/build/database/query/remove-index-base.js +39 -0
- package/build/database/record/index.js +25 -9
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/build/frontend-model-controller.js +632 -1
- package/build/frontend-models/base.js +5 -1
- package/build/frontend-models/resource-definition.js +20 -3
- package/build/http-server/client/websocket-session.js +93 -0
- package/build/routes/hooks/frontend-model-command-route-hook.js +24 -0
- package/build/src/configuration-types.d.ts +20 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +5 -1
- package/build/src/configuration.d.ts +13 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +28 -1
- package/build/src/database/drivers/base.d.ts +20 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +16 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +12 -1
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +12 -1
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +12 -1
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +12 -1
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
- package/build/src/database/migration/index.d.ts +25 -0
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +39 -1
- package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
- package/build/src/database/pool/base-methods-forward.js +2 -1
- package/build/src/database/query/remove-index-base.d.ts +35 -0
- package/build/src/database/query/remove-index-base.d.ts.map +1 -0
- package/build/src/database/query/remove-index-base.js +34 -0
- package/build/src/database/record/index.d.ts +11 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +26 -11
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
- package/build/src/frontend-model-controller.d.ts +156 -0
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +555 -2
- package/build/src/frontend-models/base.d.ts +5 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +6 -2
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +20 -4
- package/build/src/http-server/client/websocket-session.d.ts +39 -0
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +85 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.js +22 -1
- package/build/src/sync/conflict-strategy.d.ts +83 -0
- package/build/src/sync/conflict-strategy.d.ts.map +1 -0
- package/build/src/sync/conflict-strategy.js +215 -0
- package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
- package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
- package/build/src/sync/peer-mutation-bundle.js +215 -0
- package/build/src/sync/server-change-feed.d.ts +265 -0
- package/build/src/sync/server-change-feed.d.ts.map +1 -0
- package/build/src/sync/server-change-feed.js +475 -0
- package/build/sync/conflict-strategy.js +225 -0
- package/build/sync/peer-mutation-bundle.js +232 -0
- package/build/sync/server-change-feed.js +524 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/configuration-types.js +4 -0
- package/src/configuration.js +29 -0
- package/src/database/drivers/base.js +16 -0
- package/src/database/drivers/mssql/index.js +13 -0
- package/src/database/drivers/mssql/sql/remove-index.js +6 -0
- package/src/database/drivers/mysql/index.js +13 -0
- package/src/database/drivers/mysql/sql/remove-index.js +6 -0
- package/src/database/drivers/pgsql/index.js +13 -0
- package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/base.js +13 -0
- package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/src/database/migration/index.js +44 -0
- package/src/database/pool/base-methods-forward.js +1 -0
- package/src/database/query/remove-index-base.js +39 -0
- package/src/database/record/index.js +25 -9
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/src/frontend-model-controller.js +632 -1
- package/src/frontend-models/base.js +5 -1
- package/src/frontend-models/resource-definition.js +20 -3
- package/src/http-server/client/websocket-session.js +93 -0
- package/src/routes/hooks/frontend-model-command-route-hook.js +24 -0
- package/src/sync/conflict-strategy.js +225 -0
- package/src/sync/peer-mutation-bundle.js +232 -0
- package/src/sync/server-change-feed.js +524 -0
|
@@ -6,7 +6,9 @@ import FrontendModelBaseResource from "./frontend-model-resource/base-resource.j
|
|
|
6
6
|
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
|
-
import {createOfflineGrantFromBootstrap} from "./sync/offline-grant.js"
|
|
9
|
+
import {createOfflineGrantFromBootstrap, verifyOfflineGrant} from "./sync/offline-grant.js"
|
|
10
|
+
import {serverChangeFeedStoreForConfiguration} from "./sync/server-change-feed.js"
|
|
11
|
+
import {mutationIdempotencyKey, verifySignedMutation} from "./sync/device-identity.js"
|
|
10
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"
|
|
11
13
|
import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
|
|
12
14
|
import {requestDetails} from "./error-reporting/request-details.js"
|
|
@@ -172,6 +174,19 @@ const frontendModelWhereNoMatchSymbol = Symbol("frontendModelWhereNoMatch")
|
|
|
172
174
|
const frontendModelClientSafeErrorMessage = "Request failed."
|
|
173
175
|
const frontendModelDebugErrorEnvironments = new Set(["development", "test"])
|
|
174
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Builds a client-safe sync replay validation error.
|
|
179
|
+
* @param {string} message - Client-safe validation message.
|
|
180
|
+
* @param {unknown} [cause] - Original cause.
|
|
181
|
+
* @returns {VelociousError} - Client-safe replay error.
|
|
182
|
+
*/
|
|
183
|
+
function frontendSyncReplaySafeError(message, cause) {
|
|
184
|
+
return VelociousError.safe(message, {
|
|
185
|
+
cause,
|
|
186
|
+
code: "frontend_sync_replay_error"
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
175
190
|
/**
|
|
176
191
|
* Runs frontend model query metadata.
|
|
177
192
|
* @param {import("./database/query/model-class-query.js").default} query - Query instance.
|
|
@@ -3561,6 +3576,622 @@ export default class FrontendModelController extends Controller {
|
|
|
3561
3576
|
throw new Error("Expected sync bootstrap current user")
|
|
3562
3577
|
}
|
|
3563
3578
|
|
|
3579
|
+
/**
|
|
3580
|
+
* Runs frontend sync replay.
|
|
3581
|
+
* @returns {Promise<void>} - Sync replay response with per-mutation results.
|
|
3582
|
+
*/
|
|
3583
|
+
async frontendSyncReplay() {
|
|
3584
|
+
if (this.request().httpMethod() === "OPTIONS") {
|
|
3585
|
+
await this.render({status: 204, json: {}})
|
|
3586
|
+
return
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
const params = /** @type {Record<string, ?>} */ (deserializeFrontendModelTransportValue(this.params()))
|
|
3590
|
+
const signedMutations = this.frontendSyncReplaySignedMutations(params)
|
|
3591
|
+
const results = []
|
|
3592
|
+
|
|
3593
|
+
for (const signedMutation of signedMutations) {
|
|
3594
|
+
let idempotencyKey = null
|
|
3595
|
+
|
|
3596
|
+
try {
|
|
3597
|
+
idempotencyKey = mutationIdempotencyKey(/** @type {import("./sync/device-identity.js").SignedSyncMutation} */ (signedMutation))
|
|
3598
|
+
const {response, serverChangeFeedError, serverChangeFeedStatus, serverSequence} = await this.frontendSyncReplaySignedMutation(signedMutation)
|
|
3599
|
+
|
|
3600
|
+
results.push({
|
|
3601
|
+
idempotencyKey,
|
|
3602
|
+
response,
|
|
3603
|
+
serverChangeFeedError,
|
|
3604
|
+
serverChangeFeedStatus,
|
|
3605
|
+
serverSequence,
|
|
3606
|
+
status: "success"
|
|
3607
|
+
})
|
|
3608
|
+
} catch (error) {
|
|
3609
|
+
const errorContext = this.frontendModelEndpointErrorContext({
|
|
3610
|
+
action: "frontendSyncReplay",
|
|
3611
|
+
commandType: signedMutation && typeof signedMutation === "object" && "mutation" in signedMutation
|
|
3612
|
+
? /** @type {{mutation?: {operation?: ?}}} */ (signedMutation).mutation?.operation
|
|
3613
|
+
: undefined,
|
|
3614
|
+
error,
|
|
3615
|
+
model: signedMutation && typeof signedMutation === "object" && "mutation" in signedMutation
|
|
3616
|
+
? /** @type {{mutation?: {model?: ?}}} */ (signedMutation).mutation?.model
|
|
3617
|
+
: undefined
|
|
3618
|
+
})
|
|
3619
|
+
|
|
3620
|
+
await this.frontendModelLogEndpointError({
|
|
3621
|
+
action: errorContext.action,
|
|
3622
|
+
commandType: errorContext.commandType,
|
|
3623
|
+
error,
|
|
3624
|
+
model: errorContext.model
|
|
3625
|
+
})
|
|
3626
|
+
|
|
3627
|
+
results.push({
|
|
3628
|
+
idempotencyKey,
|
|
3629
|
+
response: await this.frontendModelClientErrorPayloadForError(error, errorContext),
|
|
3630
|
+
status: "error"
|
|
3631
|
+
})
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
await this.render({
|
|
3636
|
+
json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
|
|
3637
|
+
results,
|
|
3638
|
+
status: "success"
|
|
3639
|
+
}, this.transportSerializationOptions()))
|
|
3640
|
+
})
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
/**
|
|
3644
|
+
* Resolves signed replay mutations from request params.
|
|
3645
|
+
* @param {Record<string, ?>} params - Request params.
|
|
3646
|
+
* @returns {Array<?>} - Signed mutation envelopes.
|
|
3647
|
+
*/
|
|
3648
|
+
frontendSyncReplaySignedMutations(params) {
|
|
3649
|
+
if (Array.isArray(params.mutations)) return params.mutations
|
|
3650
|
+
if (params.mutation) return [params.mutation]
|
|
3651
|
+
|
|
3652
|
+
throw new Error("Expected sync replay mutation or mutations")
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/**
|
|
3656
|
+
* Verifies and replays one signed sync mutation.
|
|
3657
|
+
* @param {?} signedMutation - Signed mutation envelope.
|
|
3658
|
+
* @returns {Promise<{response: Record<string, ?>, serverChangeFeedError?: Record<string, ?>, serverChangeFeedStatus?: "error", serverSequence: number | null}>} - Frontend-model command response and appended server sequence.
|
|
3659
|
+
*/
|
|
3660
|
+
async frontendSyncReplaySignedMutation(signedMutation) {
|
|
3661
|
+
const configuration = this.getConfiguration()
|
|
3662
|
+
const syncConfiguration = configuration.getSyncConfiguration()
|
|
3663
|
+
const backendPublicKey = syncConfiguration.deviceCertificateBackendPublicKey
|
|
3664
|
+
|
|
3665
|
+
if (!backendPublicKey) throw frontendSyncReplaySafeError("sync.deviceCertificateBackendPublicKey is required for sync replay")
|
|
3666
|
+
|
|
3667
|
+
let mutation
|
|
3668
|
+
|
|
3669
|
+
try {
|
|
3670
|
+
mutation = await verifySignedMutation({
|
|
3671
|
+
backendPublicKey,
|
|
3672
|
+
signedMutation: /** @type {import("./sync/device-identity.js").SignedSyncMutation} */ (signedMutation)
|
|
3673
|
+
})
|
|
3674
|
+
} catch (error) {
|
|
3675
|
+
throw frontendSyncReplaySafeError(error instanceof Error ? error.message : String(error), error)
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
const syncManifest = frontendModelSyncManifestForBackendProjects(configuration.getBackendProjects())
|
|
3679
|
+
const syncResource = syncManifest[mutation.model]
|
|
3680
|
+
|
|
3681
|
+
if (!syncResource) throw frontendSyncReplaySafeError(`Sync replay model is not enabled: ${mutation.model}`)
|
|
3682
|
+
if (!syncResource.operations.includes(mutation.operation)) {
|
|
3683
|
+
throw frontendSyncReplaySafeError(`Sync replay operation is not enabled for ${mutation.model}: ${mutation.operation}`)
|
|
3684
|
+
}
|
|
3685
|
+
if (syncResource.policyHash !== mutation.policyHash) {
|
|
3686
|
+
throw frontendSyncReplaySafeError(`Sync replay policy hash mismatch for ${mutation.model}`)
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
const signedOfflineGrant = this.frontendSyncReplaySignedOfflineGrant(signedMutation)
|
|
3690
|
+
const offlineGrant = await this.frontendSyncReplayVerifiedOfflineGrant({
|
|
3691
|
+
signedOfflineGrant,
|
|
3692
|
+
signingKeys: syncConfiguration.offlineGrantSigningKeys
|
|
3693
|
+
})
|
|
3694
|
+
|
|
3695
|
+
this.frontendSyncReplayValidateOfflineGrant({mutation, offlineGrant, syncResource})
|
|
3696
|
+
|
|
3697
|
+
const commandParams = await this.frontendSyncReplayCommandParams(mutation)
|
|
3698
|
+
const replayCommand = this.frontendSyncReplayCommandForMutation(mutation)
|
|
3699
|
+
|
|
3700
|
+
let response
|
|
3701
|
+
|
|
3702
|
+
try {
|
|
3703
|
+
response = await this.withFrontendModelParams(commandParams, async () => {
|
|
3704
|
+
return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
|
|
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.")
|
|
3710
|
+
})
|
|
3711
|
+
})
|
|
3712
|
+
} catch (error) {
|
|
3713
|
+
const errorContext = this.frontendModelEndpointErrorContext({
|
|
3714
|
+
action: "frontendSyncReplay",
|
|
3715
|
+
commandType: /** @type {?} */ (replayCommand.commandType),
|
|
3716
|
+
error,
|
|
3717
|
+
model: mutation.model
|
|
3718
|
+
})
|
|
3719
|
+
|
|
3720
|
+
await this.frontendModelLogEndpointError({
|
|
3721
|
+
action: errorContext.action,
|
|
3722
|
+
commandType: errorContext.commandType,
|
|
3723
|
+
error,
|
|
3724
|
+
model: errorContext.model
|
|
3725
|
+
})
|
|
3726
|
+
|
|
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
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
/**
|
|
3767
|
+
* Resolves the signed offline grant carried by a replay request.
|
|
3768
|
+
* @param {?} signedMutation - Signed mutation envelope.
|
|
3769
|
+
* @returns {?} - Signed offline grant envelope.
|
|
3770
|
+
*/
|
|
3771
|
+
frontendSyncReplaySignedOfflineGrant(signedMutation) {
|
|
3772
|
+
if (!signedMutation || typeof signedMutation !== "object" || Array.isArray(signedMutation)) {
|
|
3773
|
+
throw frontendSyncReplaySafeError("Expected sync replay signed offline grant")
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
const signedMutationRecord = /** @type {Record<string, ?>} */ (signedMutation)
|
|
3777
|
+
const signedOfflineGrant = signedMutationRecord.signedOfflineGrant || signedMutationRecord.offlineGrant || signedMutationRecord.signedGrant
|
|
3778
|
+
|
|
3779
|
+
if (!signedOfflineGrant) throw frontendSyncReplaySafeError("Expected sync replay signed offline grant")
|
|
3780
|
+
|
|
3781
|
+
return signedOfflineGrant
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
/**
|
|
3785
|
+
* Verifies a sync replay signed offline grant.
|
|
3786
|
+
* @param {object} args - Arguments.
|
|
3787
|
+
* @param {?} args.signedOfflineGrant - Signed offline grant envelope.
|
|
3788
|
+
* @param {import("./sync/offline-grant.js").OfflineGrantSigningKey[]} args.signingKeys - Available signing keys.
|
|
3789
|
+
* @returns {Promise<import("./sync/offline-grant.js").OfflineGrant>} - Verified offline grant.
|
|
3790
|
+
*/
|
|
3791
|
+
async frontendSyncReplayVerifiedOfflineGrant({signedOfflineGrant, signingKeys}) {
|
|
3792
|
+
try {
|
|
3793
|
+
return await verifyOfflineGrant({
|
|
3794
|
+
now: new Date(),
|
|
3795
|
+
signedGrant: /** @type {import("./sync/offline-grant.js").SignedOfflineGrant} */ (signedOfflineGrant),
|
|
3796
|
+
signingKeys
|
|
3797
|
+
})
|
|
3798
|
+
} catch (error) {
|
|
3799
|
+
throw frontendSyncReplaySafeError(error instanceof Error ? error.message : String(error), error)
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
/**
|
|
3804
|
+
* Validates that a verified offline grant authorizes a replayed mutation.
|
|
3805
|
+
* @param {object} args - Arguments.
|
|
3806
|
+
* @param {import("./sync/device-identity.js").SyncMutation} args.mutation - Verified mutation.
|
|
3807
|
+
* @param {import("./sync/offline-grant.js").OfflineGrant} args.offlineGrant - Verified grant.
|
|
3808
|
+
* @param {Record<string, ?>} args.syncResource - Current sync resource entry.
|
|
3809
|
+
* @returns {void} - Throws when unauthorized.
|
|
3810
|
+
*/
|
|
3811
|
+
frontendSyncReplayValidateOfflineGrant({mutation, offlineGrant, syncResource}) {
|
|
3812
|
+
if (offlineGrant.grantId !== mutation.offlineGrantId) {
|
|
3813
|
+
throw frontendSyncReplaySafeError("Sync replay offline grant does not match mutation")
|
|
3814
|
+
}
|
|
3815
|
+
if (offlineGrant.deviceId !== mutation.actorDeviceId) {
|
|
3816
|
+
throw frontendSyncReplaySafeError("Sync replay offline grant device does not match mutation")
|
|
3817
|
+
}
|
|
3818
|
+
if (offlineGrant.userId !== mutation.actorUserId) {
|
|
3819
|
+
throw frontendSyncReplaySafeError("Sync replay offline grant user does not match mutation")
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
const grantResource = /** @type {Record<string, ?> | undefined} */ (offlineGrant.resources[mutation.model])
|
|
3823
|
+
const grantOperations = Array.isArray(grantResource?.operations) ? grantResource.operations : []
|
|
3824
|
+
const grantPolicyHash = grantResource?.policyHash
|
|
3825
|
+
|
|
3826
|
+
if (!grantResource || grantResource.enabled !== true) throw frontendSyncReplaySafeError(`Sync replay offline grant does not authorize ${mutation.model}`)
|
|
3827
|
+
if (!grantOperations.includes(mutation.operation)) {
|
|
3828
|
+
throw frontendSyncReplaySafeError(`Sync replay offline grant does not authorize ${mutation.model}: ${mutation.operation}`)
|
|
3829
|
+
}
|
|
3830
|
+
if (grantPolicyHash !== mutation.policyHash || grantPolicyHash !== syncResource.policyHash) {
|
|
3831
|
+
throw frontendSyncReplaySafeError(`Sync replay offline grant policy hash mismatch for ${mutation.model}`)
|
|
3832
|
+
}
|
|
3833
|
+
if (!offlineGrant.scopes || typeof offlineGrant.scopes !== "object" || Array.isArray(offlineGrant.scopes)) {
|
|
3834
|
+
throw frontendSyncReplaySafeError("Sync replay offline grant scopes are invalid")
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
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
|
+
|
|
3892
|
+
/**
|
|
3893
|
+
* Builds frontend-model command params for a verified replay mutation.
|
|
3894
|
+
* @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
|
|
3895
|
+
* @returns {Promise<Record<string, ?>>} - Frontend-model command params.
|
|
3896
|
+
*/
|
|
3897
|
+
async frontendSyncReplayCommandParams(mutation) {
|
|
3898
|
+
const payload = mutation.payload && typeof mutation.payload === "object" && !Array.isArray(mutation.payload) ? mutation.payload : {}
|
|
3899
|
+
const {attributes, primaryKeyValue} = await this.frontendSyncReplayCommandAttributes(mutation)
|
|
3900
|
+
const commandParams = /** @type {Record<string, ?>} */ ({
|
|
3901
|
+
...payload,
|
|
3902
|
+
attributes,
|
|
3903
|
+
model: mutation.model
|
|
3904
|
+
})
|
|
3905
|
+
|
|
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") {
|
|
3924
|
+
const id = commandParams.id || commandParams.recordId || primaryKeyValue
|
|
3925
|
+
|
|
3926
|
+
if (typeof id !== "string" && typeof id !== "number") throw frontendSyncReplaySafeError(`Sync replay ${mutation.operation} requires an id`)
|
|
3927
|
+
|
|
3928
|
+
commandParams.id = id
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
return commandParams
|
|
3932
|
+
}
|
|
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
|
+
|
|
3964
|
+
/**
|
|
3965
|
+
* Resolves command attributes and primary key from a replay mutation.
|
|
3966
|
+
* @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
|
|
3967
|
+
* @returns {Promise<{attributes: Record<string, ?>, primaryKeyValue: string | number | undefined}>} - Command attributes and primary key value.
|
|
3968
|
+
*/
|
|
3969
|
+
async frontendSyncReplayCommandAttributes(mutation) {
|
|
3970
|
+
const attributes = /** @type {Record<string, ?>} */ ({...(mutation.attributes || {})})
|
|
3971
|
+
const frontendModelResource = this.getConfiguration().getBackendProjects()
|
|
3972
|
+
.map((backendProject) => this.frontendModelResourceConfigurationForBackendProjectModelName({backendProject, modelName: mutation.model}))
|
|
3973
|
+
.find((resourceConfiguration) => resourceConfiguration)
|
|
3974
|
+
|
|
3975
|
+
if (!frontendModelResource) return {attributes, primaryKeyValue: undefined}
|
|
3976
|
+
|
|
3977
|
+
const primaryKey = typeof frontendModelResource.resourceConfiguration.primaryKey === "string" ? frontendModelResource.resourceConfiguration.primaryKey : "id"
|
|
3978
|
+
const primaryKeyAttribute = attributes[primaryKey]
|
|
3979
|
+
const primaryKeyValue = typeof primaryKeyAttribute === "string" || typeof primaryKeyAttribute === "number" ? primaryKeyAttribute : undefined
|
|
3980
|
+
|
|
3981
|
+
if (primaryKeyValue !== undefined && mutation.operation !== "create") delete attributes[primaryKey]
|
|
3982
|
+
|
|
3983
|
+
return {attributes, primaryKeyValue}
|
|
3984
|
+
}
|
|
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
|
+
|
|
3564
4195
|
/**
|
|
3565
4196
|
* Runs frontend api.
|
|
3566
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.
|