velocious 1.0.574 → 1.0.576
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 +17 -3
- package/build/background-jobs/client.js +71 -3
- package/build/background-jobs/job.js +30 -0
- package/build/background-jobs/main.js +100 -14
- package/build/background-jobs/store.js +344 -41
- package/build/background-jobs/types.js +25 -1
- package/build/background-jobs/web/controller.js +1 -0
- package/build/database/advisory-lock-runner.js +27 -17
- package/build/database/drivers/mssql/index.js +98 -3
- package/build/database/record/index.js +10 -8
- package/build/database/table-data/table-column.js +2 -0
- package/build/frontend-models/resource-definition.js +1 -138
- package/build/src/background-jobs/client.d.ts +30 -0
- package/build/src/background-jobs/client.d.ts.map +1 -1
- package/build/src/background-jobs/client.js +64 -4
- package/build/src/background-jobs/job.d.ts +19 -0
- package/build/src/background-jobs/job.d.ts.map +1 -1
- package/build/src/background-jobs/job.js +27 -1
- package/build/src/background-jobs/main.d.ts +41 -0
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +95 -14
- package/build/src/background-jobs/store.d.ts +136 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +314 -42
- package/build/src/background-jobs/types.d.ts +86 -2
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +26 -2
- package/build/src/background-jobs/web/controller.d.ts.map +1 -1
- package/build/src/background-jobs/web/controller.js +2 -1
- package/build/src/database/advisory-lock-runner.d.ts +27 -14
- package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
- package/build/src/database/advisory-lock-runner.js +27 -18
- package/build/src/database/drivers/mssql/index.d.ts +32 -0
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +86 -4
- package/build/src/database/record/index.d.ts +12 -8
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +11 -9
- package/build/src/database/table-data/table-column.d.ts.map +1 -1
- package/build/src/database/table-data/table-column.js +4 -1
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +2 -124
- package/build/src/sync/local-mutation-log.d.ts +7 -1
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +18 -7
- package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -1
- package/build/src/sync/peer-mutation-bundle.js +9 -7
- package/build/src/sync/signed-sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/signed-sync-envelope-replay-service.js +3 -1
- package/build/src/sync/sync-envelope-replay-service.d.ts +69 -0
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +186 -18
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +17 -22
- package/build/src/utils/sha256-hex.d.ts +10 -0
- package/build/src/utils/sha256-hex.d.ts.map +1 -0
- package/build/src/utils/sha256-hex.js +126 -0
- package/build/sync/local-mutation-log.js +19 -5
- package/build/sync/peer-mutation-bundle.js +8 -6
- package/build/sync/signed-sync-envelope-replay-service.js +2 -0
- package/build/sync/sync-envelope-replay-service.js +205 -17
- package/build/testing/test-runner.js +17 -22
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/sha256-hex.js +141 -0
- package/package.json +1 -1
- package/src/background-jobs/client.js +71 -3
- package/src/background-jobs/job.js +30 -0
- package/src/background-jobs/main.js +100 -14
- package/src/background-jobs/store.js +344 -41
- package/src/background-jobs/types.js +25 -1
- package/src/background-jobs/web/controller.js +1 -0
- package/src/database/advisory-lock-runner.js +27 -17
- package/src/database/drivers/mssql/index.js +98 -3
- package/src/database/record/index.js +10 -8
- package/src/database/table-data/table-column.js +2 -0
- package/src/frontend-models/resource-definition.js +1 -138
- package/src/sync/local-mutation-log.js +19 -5
- package/src/sync/peer-mutation-bundle.js +8 -6
- package/src/sync/signed-sync-envelope-replay-service.js +2 -0
- package/src/sync/sync-envelope-replay-service.js +205 -17
- package/src/testing/test-runner.js +17 -22
- package/src/utils/sha256-hex.js +141 -0
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
|
|
4
4
|
import {markServerApply} from "./sync-publish-suppression.js"
|
|
5
5
|
import {resolveFrontendModelResourceClass} from "../frontend-models/resource-definition.js"
|
|
6
|
+
import {resolveSyncConflict} from "./conflict-strategy.js"
|
|
6
7
|
import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
|
|
8
|
+
import stableJsonStringify from "./stable-json.js"
|
|
9
|
+
import sha256Hex from "../utils/sha256-hex.js"
|
|
7
10
|
import {ValidationError} from "../database/record/index.js"
|
|
8
11
|
import VelociousError from "../velocious-error.js"
|
|
9
12
|
|
|
@@ -16,6 +19,8 @@ import VelociousError from "../velocious-error.js"
|
|
|
16
19
|
*/
|
|
17
20
|
/**
|
|
18
21
|
* @typedef {object} SyncReplayMutation
|
|
22
|
+
* @property {string | number | null} [baseVersion] - Base server/client version observed by the client.
|
|
23
|
+
* @property {string} [clientMutationId] - Original client mutation id from the signed envelope.
|
|
19
24
|
* @property {Date} clientUpdatedAt - Client-side mutation timestamp.
|
|
20
25
|
* @property {Record<string, ?>} data - Parsed mutation payload.
|
|
21
26
|
* @property {?} id - Client sync row id for per-sync responses.
|
|
@@ -65,6 +70,7 @@ export default class SyncEnvelopeReplayService {
|
|
|
65
70
|
* @param {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [args.broadcaster] - Delivers declarative broadcasts. Required when broadcasts are configured.
|
|
66
71
|
* @param {SyncReplayBroadcast[]} [args.broadcasts] - Broadcasts fanned out by the default afterReplayMutation.
|
|
67
72
|
* @param {import("../configuration.js").default} [args.configuration] - Configuration whose frontend-model registry routes mutations to resource classes.
|
|
73
|
+
* @param {{strategy?: "optimisticVersion" | "serverWins", versionAttribute: string} | null} [args.conflictStrategy] - Optional base-version conflict detection for routed upserts. Only `optimisticVersion` and `serverWins` are supported for backend replay because the server does not have the client's base snapshot. When `strategy` is omitted it defaults to `optimisticVersion`, matching `resolveSyncConflict` and normalized resource config. When configured, a mutation whose baseVersion does not match the current server versionAttribute is rejected with a structured conflict result instead of being applied.
|
|
68
74
|
* @param {Record<string, import("../configuration-types.js").FrontendModelResourceClassType | string>} [args.resourceTypeOverrides] - Per-resourceType routing overrides: a resource class, or a string alias resolved through the registry.
|
|
69
75
|
* @param {import("../authorization/ability.js").default} [args.ability] - Ability scoping routed record lookups and create membership checks.
|
|
70
76
|
* @param {Record<string, ?>} [args.abilityContext] - Ability context passed to routed resources.
|
|
@@ -83,6 +89,7 @@ export default class SyncEnvelopeReplayService {
|
|
|
83
89
|
this.broadcasts = args.broadcasts || null
|
|
84
90
|
this.applyHandlers = args.applyHandlers ? this.builtApplyHandlers(args.applyHandlers) : null
|
|
85
91
|
this.configuration = args.configuration || null
|
|
92
|
+
this.conflictStrategy = args.conflictStrategy || null
|
|
86
93
|
this.resourceTypeOverrides = args.resourceTypeOverrides || null
|
|
87
94
|
this.ability = args.ability || null
|
|
88
95
|
this.abilityContext = args.abilityContext || null
|
|
@@ -96,6 +103,16 @@ export default class SyncEnvelopeReplayService {
|
|
|
96
103
|
if (this.broadcasts && !this.broadcaster) {
|
|
97
104
|
throw new Error("SyncEnvelopeReplayService broadcasts require a broadcaster option delivering them")
|
|
98
105
|
}
|
|
106
|
+
if (this.conflictStrategy) {
|
|
107
|
+
const supportedConflictStrategies = new Set(["optimisticVersion", "serverWins"])
|
|
108
|
+
|
|
109
|
+
if (!this.conflictStrategy.versionAttribute || typeof this.conflictStrategy.versionAttribute !== "string") {
|
|
110
|
+
throw new Error("SyncEnvelopeReplayService conflictStrategy requires a non-blank versionAttribute")
|
|
111
|
+
}
|
|
112
|
+
if (this.conflictStrategy.strategy !== undefined && !supportedConflictStrategies.has(this.conflictStrategy.strategy)) {
|
|
113
|
+
throw new Error(`Unsupported sync conflict strategy for backend replay: ${this.conflictStrategy.strategy}. Only optimisticVersion and serverWins are supported.`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
/**
|
|
@@ -181,6 +198,15 @@ export default class SyncEnvelopeReplayService {
|
|
|
181
198
|
throw error
|
|
182
199
|
}
|
|
183
200
|
|
|
201
|
+
if (applyResult && applyResult.status === "conflict") {
|
|
202
|
+
syncResponses.push({
|
|
203
|
+
conflict: applyResult.conflict,
|
|
204
|
+
id: mutation.id,
|
|
205
|
+
syncState: "conflict"
|
|
206
|
+
})
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
184
210
|
await this.persistReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
|
|
185
211
|
await this.afterReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
|
|
186
212
|
|
|
@@ -249,7 +275,7 @@ export default class SyncEnvelopeReplayService {
|
|
|
249
275
|
}
|
|
250
276
|
|
|
251
277
|
const sync = /** @type {Record<string, ?>} */ (rawSync)
|
|
252
|
-
const {clientUpdatedAt, data, id, resourceId, resourceType, syncType} = sync
|
|
278
|
+
const {clientMutationId, clientUpdatedAt, data, id, resourceId, resourceType, syncType} = sync
|
|
253
279
|
|
|
254
280
|
if (typeof resourceType !== "string" || resourceType.length < 1 || resourceId === undefined || resourceId === null || typeof syncType !== "string" || syncType.length < 1) {
|
|
255
281
|
return {ok: false, response: {id, syncState: "failed", reason: "invalid-resource-id"}}
|
|
@@ -267,6 +293,8 @@ export default class SyncEnvelopeReplayService {
|
|
|
267
293
|
return {
|
|
268
294
|
ok: true,
|
|
269
295
|
mutation: {
|
|
296
|
+
baseVersion: sync.baseVersion,
|
|
297
|
+
clientMutationId,
|
|
270
298
|
clientUpdatedAt: clientUpdatedAtDate,
|
|
271
299
|
data: normalizedDataResult.data,
|
|
272
300
|
id,
|
|
@@ -644,29 +672,160 @@ export default class SyncEnvelopeReplayService {
|
|
|
644
672
|
*/
|
|
645
673
|
async applyRoutedReplayUpsert({context, mutation, resource}) {
|
|
646
674
|
const attributes = this.permittedRoutedAttributes({mutation, resource})
|
|
647
|
-
const
|
|
675
|
+
const ModelClass = resource.modelClass()
|
|
676
|
+
const runUpsert = async () => {
|
|
677
|
+
const existingRecord = await resource.findSyncRecord({mutation})
|
|
678
|
+
const conflictResult = await this.routedReplayConflictResult({attributes, existingRecord, mutation, resource})
|
|
648
679
|
|
|
649
|
-
|
|
650
|
-
let record = existingRecord
|
|
651
|
-
let created = false
|
|
680
|
+
if (conflictResult) return conflictResult
|
|
652
681
|
|
|
653
|
-
|
|
654
|
-
|
|
682
|
+
/** @type {import("../database/record/index.js").default | null} */
|
|
683
|
+
let record = existingRecord
|
|
684
|
+
let created = false
|
|
655
685
|
|
|
656
|
-
|
|
657
|
-
existingRecord
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
686
|
+
if (existingRecord) {
|
|
687
|
+
const releaseServerApply = markServerApply(existingRecord)
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
existingRecord.assign(attributes)
|
|
691
|
+
await this.saveRoutedReplayRecord(existingRecord)
|
|
692
|
+
} finally {
|
|
693
|
+
releaseServerApply()
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
record = await this.createRoutedReplayRecord({attributes, mutation, resource})
|
|
697
|
+
created = true
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const extras = await resource.afterSyncApply({context, created, mutation, record})
|
|
701
|
+
|
|
702
|
+
return {created, deleted: false, record, ...extras}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!this.conflictStrategy) return await runUpsert()
|
|
706
|
+
|
|
707
|
+
return await ModelClass.withAdvisoryLock(syncReplayConflictLockName({resourceId: mutation.resourceId, resourceType: mutation.resourceType}), runUpsert, {dedicatedConnection: true})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Checks whether a routed upsert mutation conflicts with the current server
|
|
712
|
+
* state when the service is configured with a conflict strategy. A mutation
|
|
713
|
+
* whose baseVersion does not match the server's current versionAttribute is
|
|
714
|
+
* rejected with a structured conflict payload instead of being applied.
|
|
715
|
+
* @param {object} args - Conflict-check args.
|
|
716
|
+
* @param {Record<string, ?>} args.attributes - Permitted mutation attributes.
|
|
717
|
+
* @param {import("../database/record/index.js").default | null} args.existingRecord - Existing server record.
|
|
718
|
+
* @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
|
|
719
|
+
* @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
|
|
720
|
+
* @returns {Promise<Record<string, ?> | null>} - Conflict apply result, or null when no conflict.
|
|
721
|
+
*/
|
|
722
|
+
async routedReplayConflictResult({attributes, existingRecord, mutation, resource}) {
|
|
723
|
+
if (!this.conflictStrategy) return null
|
|
724
|
+
if (!existingRecord || mutation.syncType === "create") return null
|
|
725
|
+
if (mutation.baseVersion === undefined || mutation.baseVersion === null) return null
|
|
726
|
+
|
|
727
|
+
const versionAttribute = this.conflictStrategy.versionAttribute
|
|
728
|
+
const serverVersion = normalizeConflictValue(existingRecord.readAttribute(versionAttribute))
|
|
729
|
+
|
|
730
|
+
if (stableJsonStringify(serverVersion) === stableJsonStringify(mutation.baseVersion)) return null
|
|
731
|
+
|
|
732
|
+
const ModelClass = resource.modelClass()
|
|
733
|
+
const primaryKey = ModelClass.primaryKey()
|
|
734
|
+
const serializedAffectedAttributes = await this.serializedRoutedConflictAttributes({attributes, existingRecord, resource})
|
|
735
|
+
const serverAttributes = {
|
|
736
|
+
...serializedAffectedAttributes,
|
|
737
|
+
[primaryKey]: existingRecord.readAttribute(primaryKey),
|
|
738
|
+
[versionAttribute]: serverVersion
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const serverRecord = {
|
|
742
|
+
attributes: serverAttributes,
|
|
743
|
+
version: serverVersion
|
|
744
|
+
}
|
|
745
|
+
const conflictMutation = /** @type {import("./device-identity.js").SyncMutation} */ (/** @type {unknown} */ ({
|
|
746
|
+
attributes,
|
|
747
|
+
baseVersion: mutation.baseVersion,
|
|
748
|
+
clientMutationId: mutation.clientMutationId || mutation.id,
|
|
749
|
+
model: mutation.resourceType,
|
|
750
|
+
operation: mutation.syncType,
|
|
751
|
+
payload: {id: mutation.resourceId}
|
|
752
|
+
}))
|
|
753
|
+
const result = await resolveSyncConflict({
|
|
754
|
+
baseRecord: null,
|
|
755
|
+
mutation: conflictMutation,
|
|
756
|
+
serverRecord,
|
|
757
|
+
strategy: this.conflictStrategy.strategy || "optimisticVersion",
|
|
758
|
+
versionAttribute
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
if (result.status !== "conflict") return null
|
|
762
|
+
|
|
763
|
+
return {conflict: result.conflict, created: false, deleted: false, record: existingRecord, status: "conflict"}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Projects affected mutation fields through the resource's readable
|
|
768
|
+
* attribute contract. Writable-but-hidden fields are omitted, while custom
|
|
769
|
+
* `<attribute>Attribute(model)` serializers and model accessors remain the
|
|
770
|
+
* source of frontend-visible values. The full model attribute hash is never
|
|
771
|
+
* exposed.
|
|
772
|
+
* @param {object} args - Projection args.
|
|
773
|
+
* @param {Record<string, ?>} args.attributes - Permitted affected mutation attributes.
|
|
774
|
+
* @param {import("../database/record/index.js").default} args.existingRecord - Authorized server record.
|
|
775
|
+
* @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
|
|
776
|
+
* @returns {Promise<Record<string, ?>>} Serialized readable affected attributes.
|
|
777
|
+
*/
|
|
778
|
+
async serializedRoutedConflictAttributes({attributes, existingRecord, resource}) {
|
|
779
|
+
const ModelClass = resource.modelClass()
|
|
780
|
+
const ResourceClass = /** @type {import("../configuration-types.js").FrontendModelResourceClassType} */ (resource.constructor)
|
|
781
|
+
const readableAttributes = new Set()
|
|
782
|
+
const configuredAttributes = ResourceClass.resourceConfig().attributes
|
|
783
|
+
const configuredEntries = Array.isArray(configuredAttributes) ? configuredAttributes : Object.keys(configuredAttributes)
|
|
784
|
+
|
|
785
|
+
if (configuredEntries.length === 0) {
|
|
786
|
+
const attributeNameToColumnName = ModelClass.getAttributeNameToColumnNameMap()
|
|
787
|
+
|
|
788
|
+
for (const attributeName of Object.keys(attributeNameToColumnName)) {
|
|
789
|
+
readableAttributes.add(attributeName)
|
|
661
790
|
}
|
|
662
|
-
} else {
|
|
663
|
-
record = await this.createRoutedReplayRecord({attributes, mutation, resource})
|
|
664
|
-
created = true
|
|
665
791
|
}
|
|
666
792
|
|
|
667
|
-
const
|
|
793
|
+
for (const configuredAttribute of configuredEntries) {
|
|
794
|
+
const configuredName = typeof configuredAttribute === "string" ? configuredAttribute : configuredAttribute.name
|
|
795
|
+
|
|
796
|
+
if (!configuredName) continue
|
|
797
|
+
|
|
798
|
+
const canonicalName = ModelClass.resolveAttributeName(configuredName)
|
|
799
|
+
|
|
800
|
+
readableAttributes.add(canonicalName || configuredName)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** @type {Record<string, ?>} */
|
|
804
|
+
const serializedAttributes = {}
|
|
805
|
+
|
|
806
|
+
for (const affectedField of Object.keys(attributes)) {
|
|
807
|
+
const attributeName = ModelClass.resolveAttributeName(affectedField)
|
|
808
|
+
|
|
809
|
+
if (!attributeName || !readableAttributes.has(attributeName)) continue
|
|
668
810
|
|
|
669
|
-
|
|
811
|
+
const resourceAttribute = resource.resourceMethod(`${attributeName}Attribute`)
|
|
812
|
+
|
|
813
|
+
if (resourceAttribute) {
|
|
814
|
+
serializedAttributes[affectedField] = normalizeConflictValue(await resourceAttribute.method.call(resourceAttribute.resource, existingRecord))
|
|
815
|
+
continue
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const recordMethods = /** @type {Record<string, ?>} */ (/** @type {unknown} */ (existingRecord))
|
|
819
|
+
const attributeMethod = recordMethods[attributeName]
|
|
820
|
+
|
|
821
|
+
if (typeof attributeMethod === "function") {
|
|
822
|
+
serializedAttributes[affectedField] = normalizeConflictValue(await attributeMethod.call(existingRecord))
|
|
823
|
+
} else {
|
|
824
|
+
serializedAttributes[affectedField] = normalizeConflictValue(existingRecord.readAttribute(attributeName))
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return serializedAttributes
|
|
670
829
|
}
|
|
671
830
|
|
|
672
831
|
/**
|
|
@@ -890,3 +1049,32 @@ export default class SyncEnvelopeReplayService {
|
|
|
890
1049
|
await deliverDeclaredBroadcasts({args, broadcaster: this.broadcaster, broadcasts: this.broadcasts})
|
|
891
1050
|
}
|
|
892
1051
|
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Returns a deterministic, MySQL-safe advisory-lock name for a routed replay
|
|
1055
|
+
* resource identity. The full `{resourceType, resourceId}` identity is hashed
|
|
1056
|
+
* with SHA-256 and truncated to 32 hex characters so the final name stays well
|
|
1057
|
+
* under MySQL/MariaDB's 64-character `GET_LOCK` limit while remaining
|
|
1058
|
+
* collision-resistant.
|
|
1059
|
+
* @param {object} args - Lock identity args.
|
|
1060
|
+
* @param {string} args.resourceId - Resource id.
|
|
1061
|
+
* @param {string} args.resourceType - Resource type.
|
|
1062
|
+
* @returns {string} - Advisory lock name.
|
|
1063
|
+
*/
|
|
1064
|
+
export function syncReplayConflictLockName({resourceId, resourceType}) {
|
|
1065
|
+
const identity = stableJsonStringify({resourceId, resourceType})
|
|
1066
|
+
const hash = sha256Hex(identity).slice(0, 32)
|
|
1067
|
+
|
|
1068
|
+
return `vsr:${hash}`
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Normalizes an authoritative conflict value for JSON transport and deterministic comparison.
|
|
1073
|
+
* @param {?} value - Raw value from a database record.
|
|
1074
|
+
* @returns {?} - Normalized value (Date values become ISO strings).
|
|
1075
|
+
*/
|
|
1076
|
+
function normalizeConflictValue(value) {
|
|
1077
|
+
if (value instanceof Date) return value.toISOString()
|
|
1078
|
+
|
|
1079
|
+
return value
|
|
1080
|
+
}
|
|
@@ -828,24 +828,22 @@ export default class TestRunner {
|
|
|
828
828
|
process.on("uncaughtException", onUncaughtException)
|
|
829
829
|
|
|
830
830
|
try {
|
|
831
|
-
await this.
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
indentLevel: 0
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
// A rejection scheduled by the final test (a detached rejected promise,
|
|
841
|
-
// or an afterCommit callback rejecting as the suite drains) is reported
|
|
842
|
-
// by Node on a LATER turn. Drain a few turns while the handler is still
|
|
843
|
-
// attached — and connections still open — so those late rejections are
|
|
844
|
-
// recorded instead of escaping to the default crash path after cleanup.
|
|
845
|
-
for (let drainTurn = 0; drainTurn < 3; drainTurn++) {
|
|
846
|
-
await new Promise((resolve) => setImmediate(resolve))
|
|
847
|
-
}
|
|
831
|
+
await this.runTests({
|
|
832
|
+
afterEaches: [],
|
|
833
|
+
beforeEaches: [],
|
|
834
|
+
tests,
|
|
835
|
+
descriptions: [],
|
|
836
|
+
indentLevel: 0
|
|
848
837
|
})
|
|
838
|
+
|
|
839
|
+
// A rejection scheduled by the final test (a detached rejected promise,
|
|
840
|
+
// or an afterCommit callback rejecting as the suite drains) is reported
|
|
841
|
+
// by Node on a LATER turn. Drain a few turns while the handler is still
|
|
842
|
+
// attached so those late rejections are recorded instead of escaping to
|
|
843
|
+
// the default crash path after cleanup.
|
|
844
|
+
for (let drainTurn = 0; drainTurn < 3; drainTurn++) {
|
|
845
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
846
|
+
}
|
|
849
847
|
} finally {
|
|
850
848
|
process.off("unhandledRejection", onUnhandledRejection)
|
|
851
849
|
process.off("uncaughtException", onUncaughtException)
|
|
@@ -1005,11 +1003,8 @@ export default class TestRunner {
|
|
|
1005
1003
|
// Pin one connection per test so beforeEach, the test body and afterEach
|
|
1006
1004
|
// all run on the SAME connection. This is required for transaction-based
|
|
1007
1005
|
// database cleaning (beforeEach starts a transaction, afterEach rolls it
|
|
1008
|
-
// back).
|
|
1009
|
-
//
|
|
1010
|
-
// earlier spec closed the suite connection (which would otherwise leave a
|
|
1011
|
-
// stale async-context pin and force every later test onto a fresh checkout,
|
|
1012
|
-
// breaking isolation).
|
|
1006
|
+
// back). Releasing the lease after each lifecycle also runs the pool's
|
|
1007
|
+
// session cleanup before another test can reuse the connection.
|
|
1013
1008
|
await this.getConfiguration().ensureConnections({name: `Test: ${testDescription}`}, async () => {
|
|
1014
1009
|
// Register dynamic candidates before application hooks so transaction
|
|
1015
1010
|
// state changes made during a hook are visible to a request dispatched
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes SHA-256 without importing Node-only crypto modules, keeping this
|
|
3
|
+
* module safe for Expo/browser bundles. Implementation is a straightforward
|
|
4
|
+
* pure-JavaScript SHA-256 derived from FIPS 180-4, using only 32-bit unsigned
|
|
5
|
+
* integer arithmetic.
|
|
6
|
+
* @param {string} message - UTF-8 message.
|
|
7
|
+
* @returns {string} - Hex digest.
|
|
8
|
+
*/
|
|
9
|
+
export default function sha256Hex(message) {
|
|
10
|
+
const bytes = utf8Bytes(message)
|
|
11
|
+
const padded = [...bytes]
|
|
12
|
+
const bitLength = bytes.length * 8
|
|
13
|
+
const hash = [...SHA256_INITIAL_HASH]
|
|
14
|
+
/** @type {number[]} */
|
|
15
|
+
const words = new Array(64)
|
|
16
|
+
|
|
17
|
+
padded.push(0x80)
|
|
18
|
+
while (padded.length % 64 !== 56) padded.push(0)
|
|
19
|
+
|
|
20
|
+
const highLength = Math.floor(bitLength / 0x100000000)
|
|
21
|
+
const lowLength = bitLength >>> 0
|
|
22
|
+
|
|
23
|
+
for (const value of [highLength, lowLength]) {
|
|
24
|
+
padded.push((value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
28
|
+
for (let i = 0; i < 16; i++) {
|
|
29
|
+
const index = offset + (i * 4)
|
|
30
|
+
|
|
31
|
+
words[i] = (((padded[index] || 0) << 24) | ((padded[index + 1] || 0) << 16) | ((padded[index + 2] || 0) << 8) | (padded[index + 3] || 0)) >>> 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (let i = 16; i < 64; i++) {
|
|
35
|
+
const s0 = rotateRight(words[i - 15], 7) ^ rotateRight(words[i - 15], 18) ^ (words[i - 15] >>> 3)
|
|
36
|
+
const s1 = rotateRight(words[i - 2], 17) ^ rotateRight(words[i - 2], 19) ^ (words[i - 2] >>> 10)
|
|
37
|
+
|
|
38
|
+
words[i] = add32(words[i - 16], s0, words[i - 7], s1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let [a, b, c, d, e, f, g, h] = hash
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < 64; i++) {
|
|
44
|
+
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
|
45
|
+
const ch = (e & f) ^ ((~e) & g)
|
|
46
|
+
const temp1 = add32(h, s1, ch, SHA256_K[i], words[i])
|
|
47
|
+
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
|
48
|
+
const maj = (a & b) ^ (a & c) ^ (b & c)
|
|
49
|
+
const temp2 = add32(s0, maj)
|
|
50
|
+
|
|
51
|
+
h = g
|
|
52
|
+
g = f
|
|
53
|
+
f = e
|
|
54
|
+
e = add32(d, temp1)
|
|
55
|
+
d = c
|
|
56
|
+
c = b
|
|
57
|
+
b = a
|
|
58
|
+
a = add32(temp1, temp2)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hash[0] = add32(hash[0], a)
|
|
62
|
+
hash[1] = add32(hash[1], b)
|
|
63
|
+
hash[2] = add32(hash[2], c)
|
|
64
|
+
hash[3] = add32(hash[3], d)
|
|
65
|
+
hash[4] = add32(hash[4], e)
|
|
66
|
+
hash[5] = add32(hash[5], f)
|
|
67
|
+
hash[6] = add32(hash[6], g)
|
|
68
|
+
hash[7] = add32(hash[7], h)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return hash.map((value) => value.toString(16).padStart(8, "0")).join("")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SHA256_INITIAL_HASH = [
|
|
75
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
|
76
|
+
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
const SHA256_K = [
|
|
80
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
|
81
|
+
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
82
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
83
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
84
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
|
85
|
+
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
86
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
|
87
|
+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
88
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
89
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
90
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
|
91
|
+
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
92
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
|
93
|
+
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
94
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
95
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Converts a string to UTF-8 bytes.
|
|
100
|
+
* @param {string} value - String value.
|
|
101
|
+
* @returns {number[]} - UTF-8 bytes.
|
|
102
|
+
*/
|
|
103
|
+
function utf8Bytes(value) {
|
|
104
|
+
/** @type {number[]} */
|
|
105
|
+
const bytes = []
|
|
106
|
+
|
|
107
|
+
for (const character of value) {
|
|
108
|
+
const codePoint = /** @type {number} */ (character.codePointAt(0))
|
|
109
|
+
|
|
110
|
+
if (codePoint <= 0x7f) {
|
|
111
|
+
bytes.push(codePoint)
|
|
112
|
+
} else if (codePoint <= 0x7ff) {
|
|
113
|
+
bytes.push(0xc0 | (codePoint >>> 6), 0x80 | (codePoint & 0x3f))
|
|
114
|
+
} else if (codePoint <= 0xffff) {
|
|
115
|
+
bytes.push(0xe0 | (codePoint >>> 12), 0x80 | ((codePoint >>> 6) & 0x3f), 0x80 | (codePoint & 0x3f))
|
|
116
|
+
} else {
|
|
117
|
+
bytes.push(0xf0 | (codePoint >>> 18), 0x80 | ((codePoint >>> 12) & 0x3f), 0x80 | ((codePoint >>> 6) & 0x3f), 0x80 | (codePoint & 0x3f))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return bytes
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Adds unsigned 32-bit integers.
|
|
126
|
+
* @param {...number} values - Values to add.
|
|
127
|
+
* @returns {number} - Unsigned 32-bit result.
|
|
128
|
+
*/
|
|
129
|
+
function add32(...values) {
|
|
130
|
+
return values.reduce((sum, value) => (sum + value) >>> 0, 0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rotates a 32-bit integer right.
|
|
135
|
+
* @param {number} value - Value to rotate.
|
|
136
|
+
* @param {number} bits - Bit count.
|
|
137
|
+
* @returns {number} - Rotated value.
|
|
138
|
+
*/
|
|
139
|
+
function rotateRight(value, bits) {
|
|
140
|
+
return (value >>> bits) | (value << (32 - bits))
|
|
141
|
+
}
|