@recordtimelabel/core 0.4.3 → 0.4.5
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 +10 -6
- package/package.json +1 -1
- package/src/index.js +144 -27
- package/src/protocol.js +39 -5
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
|
|
|
17
17
|
"@recordtimelabel/core": "file:../recordtimelabel-core"
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The strict durable transport contract is prepared in package version `0.4.
|
|
20
|
+
For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The current strict durable transport contract is prepared in package version `0.4.4` (publish it before updating consumers):
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.4.
|
|
23
|
+
"@recordtimelabel/core": "0.4.4"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
|
|
@@ -111,14 +111,18 @@ Pending operations also carry an internal `syncBatchId`. Operations dispatched i
|
|
|
111
111
|
one call share a batch boundary, while legacy pending operations are assigned one
|
|
112
112
|
stable boundary during migration. The engine sends only the first FIFO batch boundary,
|
|
113
113
|
capped at 20 operations, and derives a stable request ID from that boundary and the
|
|
114
|
-
ordered
|
|
115
|
-
|
|
114
|
+
ordered canonical wire operations. Durable retry metadata does not affect request identity,
|
|
115
|
+
while reusing an operation ID with different wire content is quarantined. A bounded history
|
|
116
|
+
of completed operation fingerprints is kept in `syncMeta`; `syncBatchId`, identity fences,
|
|
117
|
+
and retry scheduling fields stay outside canonical wire operations.
|
|
116
118
|
|
|
117
119
|
Bootstrap attempts use `initial-hydration`, `rejection-rebase`, or `gap-recovery`
|
|
118
120
|
modes with a unique `attemptId`; all require a fresh logical attempt. Structured
|
|
119
121
|
failures, missing state, and missing/invalid revisions fail closed. UID or workspace
|
|
120
122
|
epoch changes clear the visible workspace and require `init()` before another dispatch
|
|
121
|
-
or sync; token refresh for the same UID/epoch only refreshes the subscription fence.
|
|
123
|
+
or sync; token refresh for the same UID/epoch only refreshes the subscription fence. An
|
|
124
|
+
operation acknowledgement records its revision diagnostically but advances
|
|
125
|
+
`remoteBaseline.revision` only with a complete state; revision gaps trigger a fresh bootstrap.
|
|
122
126
|
|
|
123
127
|
The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
|
|
124
128
|
`subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
|
|
@@ -149,7 +153,7 @@ durable state.
|
|
|
149
153
|
Gateway-only compatibility code may opt into successful legacy envelopes with
|
|
150
154
|
`{allowLegacySuccessWithoutResults: true}`. New durable clients never enable that fallback.
|
|
151
155
|
Capabilities `operation-conflict-quarantine`, `strict-operation-results`, and
|
|
152
|
-
`lifecycle-generation-fence` gate the corresponding 0.4.
|
|
156
|
+
`lifecycle-generation-fence` gate the corresponding 0.4.4 behavior. Single
|
|
153
157
|
`record.restore`, `folder.restore`, and `trash.purge` operations require a positive
|
|
154
158
|
`expectedGeneration`; batch lifecycle operations retain their existing contract. The gateway
|
|
155
159
|
validator derives enforcement from `client.capabilities`. The planner is strict by default;
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -31,7 +31,7 @@ const REQUIRED_FOLDERS = [
|
|
|
31
31
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.
|
|
34
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.5';
|
|
35
35
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
36
36
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
37
37
|
'fifo-retry-fence',
|
|
@@ -2648,6 +2648,7 @@ export const createRecordTimeLabelController = ({
|
|
|
2648
2648
|
const RTL_DURABLE_SCHEMA_VERSION = 1;
|
|
2649
2649
|
const RTL_RETRY_BASE_MS = 1000;
|
|
2650
2650
|
const RTL_RETRY_MAX_MS = 60 * 1000;
|
|
2651
|
+
const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
|
|
2651
2652
|
|
|
2652
2653
|
const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
2653
2654
|
|
|
@@ -2763,13 +2764,10 @@ const rtlNormalizePendingOperation = (operation, options = {}) => {
|
|
|
2763
2764
|
};
|
|
2764
2765
|
|
|
2765
2766
|
const rtlStableSyncBatchId = (operations = []) => {
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
hash = Math.imul(hash, 16777619);
|
|
2771
|
-
}
|
|
2772
|
-
return `durable-batch:${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
2767
|
+
return buildRecordTimeLabelRequestId(
|
|
2768
|
+
'durable-batch',
|
|
2769
|
+
toArray(operations).map((operation) => toRecordTimeLabelWireOperation(operation))
|
|
2770
|
+
);
|
|
2773
2771
|
};
|
|
2774
2772
|
|
|
2775
2773
|
const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
@@ -2883,6 +2881,34 @@ const rtlOperationWireFingerprint = (operation) => JSON.stringify(
|
|
|
2883
2881
|
canonicalizeFirestoreV2DocumentValue(toRecordTimeLabelWireOperation(operation))
|
|
2884
2882
|
);
|
|
2885
2883
|
|
|
2884
|
+
const rtlCompletedOperationFingerprint = (value) => (
|
|
2885
|
+
typeof value === 'string' ? value : value?.fingerprint
|
|
2886
|
+
);
|
|
2887
|
+
|
|
2888
|
+
const rtlRememberCompletedOperations = (workspace, completedOperations, timestamp) => {
|
|
2889
|
+
const existing = rtlDurableIsObject(workspace?.syncMeta?.completedOperationFingerprints)
|
|
2890
|
+
? workspace.syncMeta.completedOperationFingerprints
|
|
2891
|
+
: {};
|
|
2892
|
+
const history = {...existing};
|
|
2893
|
+
toArray(completedOperations).forEach(({operation, status}) => {
|
|
2894
|
+
const id = normalizeId(operation?.id);
|
|
2895
|
+
if (!id) return;
|
|
2896
|
+
delete history[id];
|
|
2897
|
+
history[id] = {
|
|
2898
|
+
fingerprint: rtlOperationWireFingerprint(operation),
|
|
2899
|
+
completedAt: timestamp,
|
|
2900
|
+
status
|
|
2901
|
+
};
|
|
2902
|
+
});
|
|
2903
|
+
const boundedHistory = Object.fromEntries(
|
|
2904
|
+
Object.entries(history).slice(-RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT)
|
|
2905
|
+
);
|
|
2906
|
+
workspace.syncMeta = {
|
|
2907
|
+
...(workspace.syncMeta || {}),
|
|
2908
|
+
completedOperationFingerprints: boundedHistory
|
|
2909
|
+
};
|
|
2910
|
+
};
|
|
2911
|
+
|
|
2886
2912
|
const rtlLifecycleOperationReason = (operation, state) => {
|
|
2887
2913
|
if (![
|
|
2888
2914
|
OPERATION_TYPES.RECORD_RESTORE,
|
|
@@ -2905,6 +2931,19 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2905
2931
|
const accepted = [];
|
|
2906
2932
|
const rejected = {...(workspace?.rejectedOperations || {})};
|
|
2907
2933
|
const knownById = new Map();
|
|
2934
|
+
Object.entries(workspace?.syncMeta?.completedOperationFingerprints || {}).forEach(([rawId, value]) => {
|
|
2935
|
+
const id = normalizeId(rawId);
|
|
2936
|
+
const fingerprint = rtlCompletedOperationFingerprint(value);
|
|
2937
|
+
if (id && typeof fingerprint === 'string' && !knownById.has(id)) {
|
|
2938
|
+
knownById.set(id, fingerprint);
|
|
2939
|
+
}
|
|
2940
|
+
});
|
|
2941
|
+
Object.values(workspace?.rejectedOperations || {}).forEach((entry) => {
|
|
2942
|
+
const id = normalizeId(entry?.operation?.id || entry?.id);
|
|
2943
|
+
if (id && entry?.operation && !knownById.has(id)) {
|
|
2944
|
+
knownById.set(id, rtlOperationWireFingerprint(entry.operation));
|
|
2945
|
+
}
|
|
2946
|
+
});
|
|
2908
2947
|
let lifecycleState = operations === workspace?.pendingOperations
|
|
2909
2948
|
? normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {})
|
|
2910
2949
|
: rtlDeriveDurableVisibleState(workspace);
|
|
@@ -3095,11 +3134,15 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3095
3134
|
session,
|
|
3096
3135
|
client = 'recordtimelabel-client',
|
|
3097
3136
|
clock = () => Date.now(),
|
|
3098
|
-
logger = console
|
|
3137
|
+
logger = console,
|
|
3138
|
+
initialBaseline = 'require-fresh'
|
|
3099
3139
|
} = {}) => {
|
|
3100
3140
|
if (!storage?.load || !storage?.save) {
|
|
3101
3141
|
throw new Error('createRecordTimeLabelSyncEngine requires storage.load() and storage.save()');
|
|
3102
3142
|
}
|
|
3143
|
+
if (initialBaseline !== 'require-fresh' && initialBaseline !== 'reuse-committed') {
|
|
3144
|
+
throw new Error('recordtimelabel_invalid_initial_baseline_policy');
|
|
3145
|
+
}
|
|
3103
3146
|
|
|
3104
3147
|
let workspace = rtlEmptyDurableWorkspace();
|
|
3105
3148
|
let initialized = false;
|
|
@@ -3226,6 +3269,49 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3226
3269
|
return {state, revision, changeCursor: remote.changeCursor ?? null};
|
|
3227
3270
|
};
|
|
3228
3271
|
|
|
3272
|
+
const requireBootstrapRevision = (baseline, minimumRevision) => {
|
|
3273
|
+
const minimum = Number(minimumRevision);
|
|
3274
|
+
if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
|
|
3275
|
+
const error = new Error('recordtimelabel_bootstrap_revision_behind_required');
|
|
3276
|
+
error.code = 'recordtimelabel_bootstrap_revision_behind_required';
|
|
3277
|
+
error.bootstrapRevision = Number(baseline?.revision);
|
|
3278
|
+
error.minimumRevision = minimum;
|
|
3279
|
+
throw error;
|
|
3280
|
+
}
|
|
3281
|
+
return baseline;
|
|
3282
|
+
};
|
|
3283
|
+
|
|
3284
|
+
// Optional bounded catch-up port. `cloud.catchUp(context, {fromRevision,
|
|
3285
|
+
// targetRevision, attemptId, mode})` resolves a FULL baseline object
|
|
3286
|
+
// (`{success: true, state, revision, changeCursor?}`, the same shape
|
|
3287
|
+
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3288
|
+
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3289
|
+
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3290
|
+
// bootstrap walk whenever it fails or is unavailable.
|
|
3291
|
+
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
3292
|
+
if (typeof cloud?.catchUp === 'function') {
|
|
3293
|
+
try {
|
|
3294
|
+
const caught = await cloud.catchUp(context, {
|
|
3295
|
+
fromRevision: workspace.remoteBaseline.revision,
|
|
3296
|
+
targetRevision,
|
|
3297
|
+
...bootstrapAttemptOptions(`${mode}-catchup`)
|
|
3298
|
+
});
|
|
3299
|
+
if (rtlEnvelopeSuccess(caught)) {
|
|
3300
|
+
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3301
|
+
}
|
|
3302
|
+
} catch (error) {
|
|
3303
|
+
logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return requireBootstrapRevision(
|
|
3307
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3308
|
+
context,
|
|
3309
|
+
bootstrapAttemptOptions(mode)
|
|
3310
|
+
)),
|
|
3311
|
+
targetRevision
|
|
3312
|
+
);
|
|
3313
|
+
};
|
|
3314
|
+
|
|
3229
3315
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
3230
3316
|
const source = loaded && typeof loaded === 'object' ? loaded : {};
|
|
3231
3317
|
const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
|
|
@@ -3302,6 +3388,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3302
3388
|
);
|
|
3303
3389
|
});
|
|
3304
3390
|
});
|
|
3391
|
+
const migrationBatchId = rtlStableSyncBatchId(migratedOperations);
|
|
3392
|
+
migratedOperations.forEach((operation) => {
|
|
3393
|
+
if (!operation.syncBatchId) operation.syncBatchId = migrationBatchId;
|
|
3394
|
+
});
|
|
3305
3395
|
loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
|
|
3306
3396
|
loadedWorkspace.pendingOperations = [
|
|
3307
3397
|
...migratedOperations,
|
|
@@ -3405,10 +3495,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3405
3495
|
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3406
3496
|
}
|
|
3407
3497
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3408
|
-
baselineValue =
|
|
3409
|
-
context,
|
|
3410
|
-
bootstrapAttemptOptions('gap-recovery')
|
|
3411
|
-
));
|
|
3498
|
+
baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
|
|
3412
3499
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3413
3500
|
}
|
|
3414
3501
|
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
@@ -3447,6 +3534,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3447
3534
|
const captured = capture();
|
|
3448
3535
|
const loaded = await storage.load();
|
|
3449
3536
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3537
|
+
// The reuse predicate must read the RAW loaded owner before
|
|
3538
|
+
// normalizeLoadedWorkspace adopts the current session identity onto the
|
|
3539
|
+
// workspace; epochs are intentionally not compared (consumers may advance
|
|
3540
|
+
// the persisted epoch on every launch).
|
|
3541
|
+
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
3542
|
+
const committedRevision = Number(loaded?.remoteBaseline?.revision || 0);
|
|
3450
3543
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
3451
3544
|
const identityChecked = rtlQuarantineOperations(
|
|
3452
3545
|
candidate,
|
|
@@ -3457,7 +3550,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3457
3550
|
candidate.rejectedOperations = identityChecked.rejected;
|
|
3458
3551
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3459
3552
|
|
|
3460
|
-
|
|
3553
|
+
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
3554
|
+
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
3555
|
+
committedRevision > 0;
|
|
3556
|
+
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
3461
3557
|
let bootstrap;
|
|
3462
3558
|
try {
|
|
3463
3559
|
bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
@@ -3727,10 +3823,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3727
3823
|
return {success: false, error, protocolError: true};
|
|
3728
3824
|
}
|
|
3729
3825
|
}
|
|
3826
|
+
const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
|
|
3730
3827
|
const candidate = clone(workspace);
|
|
3731
3828
|
const resultById = new Map(parsed.results.map((result) => [result.id, result]));
|
|
3732
3829
|
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
3733
3830
|
const appliedOperations = [];
|
|
3831
|
+
const completedOperations = [];
|
|
3734
3832
|
const nextPending = [];
|
|
3735
3833
|
const nextRejected = {...candidate.rejectedOperations};
|
|
3736
3834
|
for (const operation of candidate.pendingOperations) {
|
|
@@ -3741,12 +3839,15 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3741
3839
|
const result = resultById.get(operation.id);
|
|
3742
3840
|
if (result.status === 'applied') {
|
|
3743
3841
|
appliedOperations.push(operation);
|
|
3842
|
+
completedOperations.push({operation, status: result.status});
|
|
3744
3843
|
} else if (result.status === 'noop') {
|
|
3745
3844
|
// A noop is acknowledged but intentionally not promoted onto the
|
|
3746
3845
|
// baseline: the server says the operation had no effect.
|
|
3846
|
+
completedOperations.push({operation, status: result.status});
|
|
3747
3847
|
} else if (result.status === 'retryable') {
|
|
3748
3848
|
nextPending.push(makeRetryOperation(operation, result, null, timestamp));
|
|
3749
3849
|
} else if (result.status === 'rejected') {
|
|
3850
|
+
completedOperations.push({operation, status: result.status});
|
|
3750
3851
|
nextRejected[operation.id] = {
|
|
3751
3852
|
id: operation.id,
|
|
3752
3853
|
operation: clone(operation),
|
|
@@ -3763,13 +3864,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3763
3864
|
);
|
|
3764
3865
|
});
|
|
3765
3866
|
const responseRevision = Number(response.revision ?? response.remoteRevision);
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3867
|
+
const responseBaseline = rtlEnvelopePayload(response);
|
|
3868
|
+
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
3869
|
+
if (rtlDurableIsObject(responseState)) {
|
|
3870
|
+
applyRemoteBaseline(candidate, responseBaseline);
|
|
3871
|
+
} else if (
|
|
3872
|
+
rejectedCount === 0 &&
|
|
3873
|
+
Number.isFinite(responseRevision) &&
|
|
3874
|
+
responseRevision > candidate.remoteBaseline.revision + 1 &&
|
|
3875
|
+
typeof cloud?.bootstrap === 'function'
|
|
3876
|
+
) {
|
|
3877
|
+
const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
|
|
3878
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3879
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
3773
3880
|
}
|
|
3774
3881
|
candidate.pendingOperations = nextPending;
|
|
3775
3882
|
candidate.rejectedOperations = nextRejected;
|
|
@@ -3778,16 +3885,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3778
3885
|
lastSyncedAt: timestamp,
|
|
3779
3886
|
lastSyncAttemptAt: timestamp,
|
|
3780
3887
|
lastSyncReason: reason ?? null,
|
|
3781
|
-
lastSyncError: null
|
|
3888
|
+
lastSyncError: null,
|
|
3889
|
+
...(Number.isFinite(responseRevision) ? {
|
|
3890
|
+
lastAcknowledgedRevision: Math.max(
|
|
3891
|
+
rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
|
|
3892
|
+
candidate.remoteBaseline.revision,
|
|
3893
|
+
responseRevision
|
|
3894
|
+
)
|
|
3895
|
+
} : {})
|
|
3782
3896
|
};
|
|
3783
|
-
|
|
3897
|
+
rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
|
|
3784
3898
|
if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
|
|
3785
3899
|
let freshBaseline;
|
|
3786
3900
|
try {
|
|
3787
|
-
freshBaseline =
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3901
|
+
freshBaseline = requireBootstrapRevision(
|
|
3902
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3903
|
+
context,
|
|
3904
|
+
bootstrapAttemptOptions('rejection-rebase')
|
|
3905
|
+
)),
|
|
3906
|
+
responseRevision
|
|
3907
|
+
);
|
|
3791
3908
|
} catch (error) {
|
|
3792
3909
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3793
3910
|
throw error;
|
package/src/protocol.js
CHANGED
|
@@ -59,6 +59,37 @@ const fnv1a = (value) => {
|
|
|
59
59
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
const djb2 = (value) => {
|
|
63
|
+
let hash = 5381;
|
|
64
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
65
|
+
hash = Math.imul(hash, 33) ^ value.charCodeAt(index);
|
|
66
|
+
}
|
|
67
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const canonicalizeRequestValue = (value, seen = new WeakSet()) => {
|
|
71
|
+
if (value === null || value === undefined || typeof value !== 'object') return value;
|
|
72
|
+
if (seen.has(value)) throw new TypeError('operation_request_circular_value');
|
|
73
|
+
seen.add(value);
|
|
74
|
+
try {
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.map((entry) => canonicalizeRequestValue(entry, seen));
|
|
77
|
+
}
|
|
78
|
+
if (typeof value.toJSON === 'function') {
|
|
79
|
+
return canonicalizeRequestValue(value.toJSON(), seen);
|
|
80
|
+
}
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
Object.keys(value)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((key) => [key, canonicalizeRequestValue(value[key], seen)])
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
seen.delete(value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const requestDigest = (value) => `${fnv1a(value)}${djb2(value)}`;
|
|
92
|
+
|
|
62
93
|
export const toRecordTimeLabelWireOperation = (operation = {}) => {
|
|
63
94
|
const source = asObject(operation);
|
|
64
95
|
return {
|
|
@@ -73,14 +104,17 @@ export const toRecordTimeLabelWireOperation = (operation = {}) => {
|
|
|
73
104
|
export const buildRecordTimeLabelRequestId = (namespace, operations = []) => {
|
|
74
105
|
const safeNamespace = normalizeId(namespace)
|
|
75
106
|
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
76
|
-
.slice(0,
|
|
77
|
-
const
|
|
78
|
-
? operations.map((operation) =>
|
|
107
|
+
.slice(0, 48) || 'recordtimelabel';
|
|
108
|
+
const wireOperations = Array.isArray(operations)
|
|
109
|
+
? operations.map((operation) => toRecordTimeLabelWireOperation(operation))
|
|
79
110
|
: [];
|
|
111
|
+
const orderedIds = wireOperations.map((operation) => normalizeId(operation.id));
|
|
80
112
|
const firstOperationId = (orderedIds[0] || 'empty')
|
|
81
113
|
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
82
|
-
.slice(0,
|
|
83
|
-
|
|
114
|
+
.slice(0, 56) || 'empty';
|
|
115
|
+
const orderedIdDigest = requestDigest(orderedIds.join('\u001f'));
|
|
116
|
+
const wireDigest = requestDigest(JSON.stringify(canonicalizeRequestValue(wireOperations)));
|
|
117
|
+
return `rtl:${safeNamespace}:${firstOperationId}:${orderedIdDigest}:${wireDigest}`.slice(0, 160);
|
|
84
118
|
};
|
|
85
119
|
|
|
86
120
|
const normalizeStatus = (result) => {
|