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.
Files changed (116) hide show
  1. package/README.md +3 -0
  2. package/build/configuration-types.js +4 -0
  3. package/build/configuration.js +29 -0
  4. package/build/database/drivers/base.js +16 -0
  5. package/build/database/drivers/mssql/index.js +13 -0
  6. package/build/database/drivers/mssql/sql/remove-index.js +6 -0
  7. package/build/database/drivers/mysql/index.js +13 -0
  8. package/build/database/drivers/mysql/sql/remove-index.js +6 -0
  9. package/build/database/drivers/pgsql/index.js +13 -0
  10. package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
  11. package/build/database/drivers/sqlite/base.js +13 -0
  12. package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
  13. package/build/database/migration/index.js +44 -0
  14. package/build/database/pool/base-methods-forward.js +1 -0
  15. package/build/database/query/remove-index-base.js +39 -0
  16. package/build/database/record/index.js +25 -9
  17. package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
  18. package/build/frontend-model-controller.js +632 -1
  19. package/build/frontend-models/base.js +5 -1
  20. package/build/frontend-models/resource-definition.js +20 -3
  21. package/build/http-server/client/websocket-session.js +93 -0
  22. package/build/routes/hooks/frontend-model-command-route-hook.js +24 -0
  23. package/build/src/configuration-types.d.ts +20 -0
  24. package/build/src/configuration-types.d.ts.map +1 -1
  25. package/build/src/configuration-types.js +5 -1
  26. package/build/src/configuration.d.ts +13 -0
  27. package/build/src/configuration.d.ts.map +1 -1
  28. package/build/src/configuration.js +28 -1
  29. package/build/src/database/drivers/base.d.ts +20 -0
  30. package/build/src/database/drivers/base.d.ts.map +1 -1
  31. package/build/src/database/drivers/base.js +16 -1
  32. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  33. package/build/src/database/drivers/mssql/index.js +12 -1
  34. package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
  35. package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
  36. package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
  37. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  38. package/build/src/database/drivers/mysql/index.js +12 -1
  39. package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
  40. package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
  41. package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
  42. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  43. package/build/src/database/drivers/pgsql/index.js +12 -1
  44. package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
  45. package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
  46. package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
  47. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  48. package/build/src/database/drivers/sqlite/base.js +12 -1
  49. package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
  50. package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
  51. package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
  52. package/build/src/database/migration/index.d.ts +25 -0
  53. package/build/src/database/migration/index.d.ts.map +1 -1
  54. package/build/src/database/migration/index.js +39 -1
  55. package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
  56. package/build/src/database/pool/base-methods-forward.js +2 -1
  57. package/build/src/database/query/remove-index-base.d.ts +35 -0
  58. package/build/src/database/query/remove-index-base.d.ts.map +1 -0
  59. package/build/src/database/query/remove-index-base.js +34 -0
  60. package/build/src/database/record/index.d.ts +11 -0
  61. package/build/src/database/record/index.d.ts.map +1 -1
  62. package/build/src/database/record/index.js +26 -11
  63. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
  64. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  65. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
  66. package/build/src/frontend-model-controller.d.ts +156 -0
  67. package/build/src/frontend-model-controller.d.ts.map +1 -1
  68. package/build/src/frontend-model-controller.js +555 -2
  69. package/build/src/frontend-models/base.d.ts +5 -0
  70. package/build/src/frontend-models/base.d.ts.map +1 -1
  71. package/build/src/frontend-models/base.js +6 -2
  72. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  73. package/build/src/frontend-models/resource-definition.js +20 -4
  74. package/build/src/http-server/client/websocket-session.d.ts +39 -0
  75. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  76. package/build/src/http-server/client/websocket-session.js +85 -1
  77. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  78. package/build/src/routes/hooks/frontend-model-command-route-hook.js +22 -1
  79. package/build/src/sync/conflict-strategy.d.ts +83 -0
  80. package/build/src/sync/conflict-strategy.d.ts.map +1 -0
  81. package/build/src/sync/conflict-strategy.js +215 -0
  82. package/build/src/sync/peer-mutation-bundle.d.ts +97 -0
  83. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -0
  84. package/build/src/sync/peer-mutation-bundle.js +215 -0
  85. package/build/src/sync/server-change-feed.d.ts +265 -0
  86. package/build/src/sync/server-change-feed.d.ts.map +1 -0
  87. package/build/src/sync/server-change-feed.js +475 -0
  88. package/build/sync/conflict-strategy.js +225 -0
  89. package/build/sync/peer-mutation-bundle.js +232 -0
  90. package/build/sync/server-change-feed.js +524 -0
  91. package/build/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/src/configuration-types.js +4 -0
  94. package/src/configuration.js +29 -0
  95. package/src/database/drivers/base.js +16 -0
  96. package/src/database/drivers/mssql/index.js +13 -0
  97. package/src/database/drivers/mssql/sql/remove-index.js +6 -0
  98. package/src/database/drivers/mysql/index.js +13 -0
  99. package/src/database/drivers/mysql/sql/remove-index.js +6 -0
  100. package/src/database/drivers/pgsql/index.js +13 -0
  101. package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
  102. package/src/database/drivers/sqlite/base.js +13 -0
  103. package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
  104. package/src/database/migration/index.js +44 -0
  105. package/src/database/pool/base-methods-forward.js +1 -0
  106. package/src/database/query/remove-index-base.js +39 -0
  107. package/src/database/record/index.js +25 -9
  108. package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
  109. package/src/frontend-model-controller.js +632 -1
  110. package/src/frontend-models/base.js +5 -1
  111. package/src/frontend-models/resource-definition.js +20 -3
  112. package/src/http-server/client/websocket-session.js +93 -0
  113. package/src/routes/hooks/frontend-model-command-route-hook.js +24 -0
  114. package/src/sync/conflict-strategy.js +225 -0
  115. package/src/sync/peer-mutation-bundle.js +232 -0
  116. 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 {{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.