@recordtimelabel/core 0.6.4 → 0.6.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 +15 -3
- package/package.json +1 -1
- package/src/index.js +320 -56
- package/src/protocol.js +9 -0
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 release target is `0.6.
|
|
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 release target is `0.6.5`; verify that its registry tarball and lockfile integrity are available before updating consumers:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.6.
|
|
23
|
+
"@recordtimelabel/core": "0.6.5"
|
|
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.
|
|
@@ -134,10 +134,22 @@ The persisted workspace is versioned and contains only durable data:
|
|
|
134
134
|
remoteBaseline: { state, revision: 12, changeCursor: 'cursor-12' },
|
|
135
135
|
pendingOperations: [],
|
|
136
136
|
rejectedOperations: {},
|
|
137
|
-
syncMeta: {
|
|
137
|
+
syncMeta: {
|
|
138
|
+
// Core-owned resume fence; the cursor itself remains opaque adapter data.
|
|
139
|
+
changeCursorBinding: {
|
|
140
|
+
ownerUid: 'user-id', uid: 'user-id', workspaceEpoch: 3,
|
|
141
|
+
authSessionBinding: 'auth-session-3'
|
|
142
|
+
}
|
|
143
|
+
}
|
|
138
144
|
}
|
|
139
145
|
```
|
|
140
146
|
|
|
147
|
+
`remoteBaseline.changeCursor` is an opaque string owned by the cloud adapter and
|
|
148
|
+
must remain byte-for-byte stable across persistence. Core stores the owner,
|
|
149
|
+
workspace-epoch, and non-credential auth binding separately in
|
|
150
|
+
`syncMeta.changeCursorBinding` so a committed workspace can be resumed without
|
|
151
|
+
embedding session credentials in the cursor.
|
|
152
|
+
|
|
141
153
|
Every normalized pending operation also carries `ownerUid` and a non-negative
|
|
142
154
|
`workspaceEpoch`. Dispatch stamps these fields from the captured session; legacy
|
|
143
155
|
operations missing them inherit the migrated workspace identity. An operation whose
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -141,7 +141,7 @@ export {
|
|
|
141
141
|
selectBestMatchingTwitchVod
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
144
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.5';
|
|
145
145
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
146
146
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
147
147
|
'fifo-retry-fence',
|
|
@@ -1041,9 +1041,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1041
1041
|
|
|
1042
1042
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
1043
1043
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
1044
|
-
const rawTargetFolderId =
|
|
1044
|
+
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
1045
1045
|
if (!recordId || !rtlIsSafeDocumentId(recordId) ||
|
|
1046
|
-
|
|
1046
|
+
!rtlIsSafeDocumentId(rawTargetFolderId)) return normalized;
|
|
1047
1047
|
const targetFolderId = safeFolderId(rawTargetFolderId);
|
|
1048
1048
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
1049
1049
|
if (!entry) return normalized;
|
|
@@ -3288,20 +3288,20 @@ const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
|
|
|
3288
3288
|
!Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
|
|
3289
3289
|
!Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
|
|
3290
3290
|
!rtlHasCompleteBaselineState(baseline.state) ||
|
|
3291
|
-
!rtlRawNonNegativeSafeInteger(baseline.revision)
|
|
3292
|
-
baseline.revision <= 0) {
|
|
3293
|
-
return false;
|
|
3294
|
-
}
|
|
3295
|
-
if (!rtlResumeIdentityMatches(
|
|
3296
|
-
baseline.changeCursor,
|
|
3297
|
-
captured,
|
|
3298
|
-
ownerUid,
|
|
3299
|
-
loaded.workspaceEpoch
|
|
3300
|
-
)) {
|
|
3291
|
+
!rtlRawNonNegativeSafeInteger(baseline.revision)) {
|
|
3301
3292
|
return false;
|
|
3302
3293
|
}
|
|
3303
3294
|
if (loaded.syncMeta !== undefined &&
|
|
3304
3295
|
!rtlDurableIsObject(loaded.syncMeta)) return false;
|
|
3296
|
+
if (typeof baseline.changeCursor !== 'string' ||
|
|
3297
|
+
!rtlResumeIdentityMatches(
|
|
3298
|
+
loaded.syncMeta?.changeCursorBinding,
|
|
3299
|
+
captured,
|
|
3300
|
+
ownerUid,
|
|
3301
|
+
loaded.workspaceEpoch
|
|
3302
|
+
)) {
|
|
3303
|
+
return false;
|
|
3304
|
+
}
|
|
3305
3305
|
const resumeMarker = loaded.syncMeta?.resumeMarker ??
|
|
3306
3306
|
loaded.syncMeta?.resume ??
|
|
3307
3307
|
loaded.syncMeta?.bootstrapMarker;
|
|
@@ -3358,11 +3358,54 @@ const rtlStripSessionTokens = (value) => {
|
|
|
3358
3358
|
const result = {};
|
|
3359
3359
|
Object.entries(value).forEach(([key, entry]) => {
|
|
3360
3360
|
if (rtlIsCredentialKey(key)) return;
|
|
3361
|
-
result
|
|
3361
|
+
Object.defineProperty(result, key, {
|
|
3362
|
+
value: rtlStripSessionTokens(entry),
|
|
3363
|
+
enumerable: true,
|
|
3364
|
+
configurable: true,
|
|
3365
|
+
writable: true
|
|
3366
|
+
});
|
|
3362
3367
|
});
|
|
3363
3368
|
return result;
|
|
3364
3369
|
};
|
|
3365
3370
|
|
|
3371
|
+
// Domain state and operation payloads are opaque to Core. They may legitimately
|
|
3372
|
+
// contain fields such as `designToken` or `session` that are part of the user's
|
|
3373
|
+
// data, so the session-secret scrubber is limited to Core-owned envelopes.
|
|
3374
|
+
const rtlSanitizeDurableOperation = (operation) => {
|
|
3375
|
+
const sanitized = rtlStripSessionTokens(operation);
|
|
3376
|
+
if (rtlDurableIsObject(operation) &&
|
|
3377
|
+
Object.prototype.hasOwnProperty.call(operation, 'payload')) {
|
|
3378
|
+
sanitized.payload = clone(operation.payload);
|
|
3379
|
+
}
|
|
3380
|
+
return sanitized;
|
|
3381
|
+
};
|
|
3382
|
+
|
|
3383
|
+
const rtlSanitizeDurableWorkspace = (workspace) => {
|
|
3384
|
+
const sanitized = rtlStripSessionTokens(workspace);
|
|
3385
|
+
if (!rtlDurableIsObject(workspace)) return sanitized;
|
|
3386
|
+
if (rtlDurableIsObject(workspace.remoteBaseline) &&
|
|
3387
|
+
Object.prototype.hasOwnProperty.call(workspace.remoteBaseline, 'state')) {
|
|
3388
|
+
sanitized.remoteBaseline = {
|
|
3389
|
+
...sanitized.remoteBaseline,
|
|
3390
|
+
state: clone(workspace.remoteBaseline.state)
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
if (Array.isArray(workspace.pendingOperations)) {
|
|
3394
|
+
sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
|
|
3395
|
+
}
|
|
3396
|
+
if (rtlDurableIsObject(workspace.rejectedOperations)) {
|
|
3397
|
+
sanitized.rejectedOperations = {...sanitized.rejectedOperations};
|
|
3398
|
+
Object.entries(workspace.rejectedOperations).forEach(([id, entry]) => {
|
|
3399
|
+
if (!rtlDurableIsObject(entry) || !rtlDurableIsObject(entry.operation)) return;
|
|
3400
|
+
sanitized.rejectedOperations[id] = {
|
|
3401
|
+
...sanitized.rejectedOperations[id],
|
|
3402
|
+
operation: rtlSanitizeDurableOperation(entry.operation)
|
|
3403
|
+
};
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
return sanitized;
|
|
3407
|
+
};
|
|
3408
|
+
|
|
3366
3409
|
const rtlNormalizeOperation = (operation = {}, {
|
|
3367
3410
|
client,
|
|
3368
3411
|
clientId,
|
|
@@ -3389,7 +3432,7 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3389
3432
|
...rtlStripSessionTokens(input),
|
|
3390
3433
|
// Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
|
|
3391
3434
|
// invalid whitespace or hostile ID instead of receiving a silently rewritten one.
|
|
3392
|
-
id:
|
|
3435
|
+
id: input.id === null || input.id === undefined ? generatedId : input.id,
|
|
3393
3436
|
type: typeof input.type === 'string' ? input.type.trim() : input.type,
|
|
3394
3437
|
payload: normalizedPayload,
|
|
3395
3438
|
clientId: resolvedClientId,
|
|
@@ -3441,6 +3484,72 @@ const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) =>
|
|
|
3441
3484
|
syncMeta: {}
|
|
3442
3485
|
});
|
|
3443
3486
|
|
|
3487
|
+
const RTL_BOOTSTRAP_RESUME_META_KEYS = [
|
|
3488
|
+
'resumeMarker',
|
|
3489
|
+
'resume',
|
|
3490
|
+
'bootstrapMarker',
|
|
3491
|
+
'authSessionBinding',
|
|
3492
|
+
'changeCursorBinding'
|
|
3493
|
+
];
|
|
3494
|
+
|
|
3495
|
+
const rtlForceOperationIdentity = (operation, ownerUid, workspaceEpoch, options = {}) => ({
|
|
3496
|
+
...rtlNormalizePendingOperation(operation, {
|
|
3497
|
+
...options,
|
|
3498
|
+
ownerUid,
|
|
3499
|
+
workspaceEpoch
|
|
3500
|
+
}),
|
|
3501
|
+
ownerUid: rtlNormalizeUid(ownerUid),
|
|
3502
|
+
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0)
|
|
3503
|
+
});
|
|
3504
|
+
|
|
3505
|
+
const rtlClearBootstrapResumeMeta = (syncMeta) => {
|
|
3506
|
+
const next = rtlDurableIsObject(syncMeta) ? {...syncMeta} : {};
|
|
3507
|
+
RTL_BOOTSTRAP_RESUME_META_KEYS.forEach((key) => {
|
|
3508
|
+
delete next[key];
|
|
3509
|
+
});
|
|
3510
|
+
return next;
|
|
3511
|
+
};
|
|
3512
|
+
|
|
3513
|
+
const rtlRebindSameOwnerWorkspaceEpoch = (workspace, captured, now) => {
|
|
3514
|
+
const ownerUid = rtlNormalizeUid(captured?.uid);
|
|
3515
|
+
const workspaceEpoch = rtlNormalizeWorkspaceEpoch(captured?.workspaceEpoch, 0);
|
|
3516
|
+
const next = clone(workspace);
|
|
3517
|
+
next.ownerUid = ownerUid;
|
|
3518
|
+
next.workspaceEpoch = workspaceEpoch;
|
|
3519
|
+
next.pendingOperations = toArray(next.pendingOperations).map((operation) => (
|
|
3520
|
+
rtlForceOperationIdentity(operation, ownerUid, workspaceEpoch, {now})
|
|
3521
|
+
));
|
|
3522
|
+
if (rtlDurableIsObject(next.rejectedOperations)) {
|
|
3523
|
+
const rejected = {};
|
|
3524
|
+
Object.entries(next.rejectedOperations).forEach(([id, entry]) => {
|
|
3525
|
+
const value = rtlDurableIsObject(entry) ? {...entry} : entry;
|
|
3526
|
+
if (rtlDurableIsObject(value) && value.operation) {
|
|
3527
|
+
value.operation = rtlForceOperationIdentity(
|
|
3528
|
+
value.operation,
|
|
3529
|
+
ownerUid,
|
|
3530
|
+
workspaceEpoch,
|
|
3531
|
+
{now}
|
|
3532
|
+
);
|
|
3533
|
+
}
|
|
3534
|
+
Object.defineProperty(rejected, id, {
|
|
3535
|
+
value,
|
|
3536
|
+
enumerable: true,
|
|
3537
|
+
configurable: true,
|
|
3538
|
+
writable: true
|
|
3539
|
+
});
|
|
3540
|
+
});
|
|
3541
|
+
next.rejectedOperations = rejected;
|
|
3542
|
+
}
|
|
3543
|
+
next.remoteBaseline = {
|
|
3544
|
+
...(rtlDurableIsObject(next.remoteBaseline)
|
|
3545
|
+
? next.remoteBaseline
|
|
3546
|
+
: rtlNormalizeRemoteBaseline({})),
|
|
3547
|
+
changeCursor: null
|
|
3548
|
+
};
|
|
3549
|
+
next.syncMeta = rtlClearBootstrapResumeMeta(next.syncMeta);
|
|
3550
|
+
return next;
|
|
3551
|
+
};
|
|
3552
|
+
|
|
3444
3553
|
const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
3445
3554
|
const source = rtlDurableIsObject(input) ? input : {};
|
|
3446
3555
|
const legacy = !source.remoteBaseline && (
|
|
@@ -3479,7 +3588,12 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3479
3588
|
Object.entries(rejectedSource).forEach(([rawId, value]) => {
|
|
3480
3589
|
const id = normalizeId(rawId || value?.id || value?.operationId);
|
|
3481
3590
|
if (!id) return;
|
|
3482
|
-
rejectedOperations
|
|
3591
|
+
Object.defineProperty(rejectedOperations, id, {
|
|
3592
|
+
value: clone(value && typeof value === 'object' ? value : {reason: value}),
|
|
3593
|
+
enumerable: true,
|
|
3594
|
+
configurable: true,
|
|
3595
|
+
writable: true
|
|
3596
|
+
});
|
|
3483
3597
|
if (rejectedOperations[id].operation) {
|
|
3484
3598
|
rejectedOperations[id].operation = rtlNormalizePendingOperation(
|
|
3485
3599
|
rejectedOperations[id].operation,
|
|
@@ -3506,11 +3620,13 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3506
3620
|
const rtlOperationIdentityReason = (operation, workspace) => {
|
|
3507
3621
|
const candidateIds = [
|
|
3508
3622
|
operation?.id,
|
|
3623
|
+
operation?.payload?.id,
|
|
3509
3624
|
operation?.payload?.recordId,
|
|
3510
3625
|
operation?.payload?.folderId,
|
|
3511
3626
|
operation?.payload?.targetFolderId,
|
|
3512
3627
|
operation?.payload?.trashEntryId,
|
|
3513
3628
|
operation?.payload?.record?.id,
|
|
3629
|
+
operation?.payload?.record?.folderId,
|
|
3514
3630
|
operation?.payload?.folder?.id,
|
|
3515
3631
|
...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
|
|
3516
3632
|
...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
|
|
@@ -3651,7 +3767,8 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3651
3767
|
}
|
|
3652
3768
|
rejectedCount += 1;
|
|
3653
3769
|
const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
|
|
3654
|
-
rejected
|
|
3770
|
+
Object.defineProperty(rejected, rejectedId, {
|
|
3771
|
+
value: {
|
|
3655
3772
|
id: rejectedId,
|
|
3656
3773
|
operation: clone(operation),
|
|
3657
3774
|
status: 'rejected',
|
|
@@ -3666,7 +3783,11 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3666
3783
|
),
|
|
3667
3784
|
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
|
|
3668
3785
|
}
|
|
3669
|
-
|
|
3786
|
+
},
|
|
3787
|
+
enumerable: true,
|
|
3788
|
+
configurable: true,
|
|
3789
|
+
writable: true
|
|
3790
|
+
});
|
|
3670
3791
|
});
|
|
3671
3792
|
return {accepted, rejected, rejectedCount, deduplicatedCount};
|
|
3672
3793
|
};
|
|
@@ -3741,8 +3862,6 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3741
3862
|
return candidate;
|
|
3742
3863
|
};
|
|
3743
3864
|
if (value && typeof value === 'object') {
|
|
3744
|
-
if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
|
|
3745
|
-
if (value.baseline) return normalizeRevisionAlias(value.baseline);
|
|
3746
3865
|
// A canonical failure may carry an authoritative baseline for an
|
|
3747
3866
|
// operation-id conflict. It must win over compatibility state fields
|
|
3748
3867
|
// left beside `failure`; otherwise a stale outer state can be applied
|
|
@@ -3751,6 +3870,8 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3751
3870
|
const nested = rtlEnvelopePayload(value.failure);
|
|
3752
3871
|
if (nested) return nested;
|
|
3753
3872
|
}
|
|
3873
|
+
if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
|
|
3874
|
+
if (value.baseline) return normalizeRevisionAlias(value.baseline);
|
|
3754
3875
|
if (
|
|
3755
3876
|
value.state !== undefined ||
|
|
3756
3877
|
value.data !== undefined ||
|
|
@@ -3980,17 +4101,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3980
4101
|
const persist = async (candidate, captured) => {
|
|
3981
4102
|
if (!(await isCurrent(captured))) return false;
|
|
3982
4103
|
const cursor = candidate?.remoteBaseline?.changeCursor;
|
|
4104
|
+
candidate.syncMeta = rtlDurableIsObject(candidate.syncMeta)
|
|
4105
|
+
? {...candidate.syncMeta}
|
|
4106
|
+
: {};
|
|
3983
4107
|
if (cursor !== null && cursor !== undefined) {
|
|
3984
|
-
|
|
3985
|
-
candidate.remoteBaseline.changeCursor = {
|
|
3986
|
-
...durableCursor,
|
|
4108
|
+
candidate.syncMeta.changeCursorBinding = {
|
|
3987
4109
|
ownerUid: captured?.uid ?? null,
|
|
3988
4110
|
uid: captured?.uid ?? null,
|
|
3989
4111
|
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
|
|
3990
4112
|
authSessionBinding: captured?.authSessionBinding ?? null
|
|
3991
4113
|
};
|
|
4114
|
+
} else {
|
|
4115
|
+
delete candidate.syncMeta.changeCursorBinding;
|
|
3992
4116
|
}
|
|
3993
|
-
const durableCandidate =
|
|
4117
|
+
const durableCandidate = rtlSanitizeDurableWorkspace(candidate);
|
|
3994
4118
|
// The second argument is an optional adapter-side fence. The public
|
|
3995
4119
|
// StoragePort remains compatible with save(workspace); adapters that can
|
|
3996
4120
|
// enforce an atomic session/epoch check should consume this context before
|
|
@@ -4284,7 +4408,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4284
4408
|
const epochMismatch = Boolean(
|
|
4285
4409
|
captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
|
|
4286
4410
|
);
|
|
4287
|
-
if (ownerMismatch
|
|
4411
|
+
if (ownerMismatch) {
|
|
4412
|
+
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
4413
|
+
}
|
|
4414
|
+
if (epochMismatch) {
|
|
4415
|
+
if (
|
|
4416
|
+
sourceOwnerUid &&
|
|
4417
|
+
sourceOwnerUid === currentUid &&
|
|
4418
|
+
Number(currentEpoch) > Number(loadedWorkspace.workspaceEpoch)
|
|
4419
|
+
) {
|
|
4420
|
+
return rtlRebindSameOwnerWorkspaceEpoch(loadedWorkspace, captured, now);
|
|
4421
|
+
}
|
|
4288
4422
|
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
4289
4423
|
}
|
|
4290
4424
|
const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
|
|
@@ -4358,6 +4492,30 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4358
4492
|
...extra
|
|
4359
4493
|
});
|
|
4360
4494
|
|
|
4495
|
+
const persistHydrationBarrier = async (captured, extraMeta = {}) => {
|
|
4496
|
+
hydrationRequired = true;
|
|
4497
|
+
const candidate = clone(workspace);
|
|
4498
|
+
candidate.syncMeta = {
|
|
4499
|
+
...candidate.syncMeta,
|
|
4500
|
+
hydrationRequired: true,
|
|
4501
|
+
...extraMeta
|
|
4502
|
+
};
|
|
4503
|
+
if (!(await persist(candidate, captured))) return false;
|
|
4504
|
+
workspace = candidate;
|
|
4505
|
+
return true;
|
|
4506
|
+
};
|
|
4507
|
+
|
|
4508
|
+
const clearPersistedHydrationBarrier = (candidate) => {
|
|
4509
|
+
if (!rtlDurableIsObject(candidate?.syncMeta) ||
|
|
4510
|
+
!Object.prototype.hasOwnProperty.call(candidate.syncMeta, 'hydrationRequired')) {
|
|
4511
|
+
return candidate;
|
|
4512
|
+
}
|
|
4513
|
+
const nextMeta = {...candidate.syncMeta};
|
|
4514
|
+
delete nextMeta.hydrationRequired;
|
|
4515
|
+
candidate.syncMeta = nextMeta;
|
|
4516
|
+
return candidate;
|
|
4517
|
+
};
|
|
4518
|
+
|
|
4361
4519
|
const persistTerminalFailureBlock = async ({
|
|
4362
4520
|
failure,
|
|
4363
4521
|
error,
|
|
@@ -4393,6 +4551,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4393
4551
|
identityRejectedCount
|
|
4394
4552
|
}) => {
|
|
4395
4553
|
hydrationRequired = true;
|
|
4554
|
+
if (!(await persistHydrationBarrier(captured, {
|
|
4555
|
+
lastSyncAttemptAt: timestamp,
|
|
4556
|
+
lastSyncFailure: clone(failure),
|
|
4557
|
+
lastSyncError: failure.message || failure.code || 'bootstrap-required'
|
|
4558
|
+
}))) {
|
|
4559
|
+
return {success: false, reason: 'stale_session'};
|
|
4560
|
+
}
|
|
4396
4561
|
if (typeof cloud?.bootstrap !== 'function') {
|
|
4397
4562
|
return failureOutcome(failure, error, {
|
|
4398
4563
|
bootstrapRequired: true,
|
|
@@ -4431,6 +4596,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4431
4596
|
candidate.syncMeta.lastSyncFailure = clone(failure);
|
|
4432
4597
|
candidate.syncMeta.lastSyncAttemptAt = timestamp;
|
|
4433
4598
|
candidate.syncMeta.lastSyncError = null;
|
|
4599
|
+
clearPersistedHydrationBarrier(candidate);
|
|
4434
4600
|
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4435
4601
|
workspace = candidate;
|
|
4436
4602
|
hydrationRequired = false;
|
|
@@ -4614,14 +4780,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4614
4780
|
const completedOperations = [];
|
|
4615
4781
|
ready.forEach((operation) => {
|
|
4616
4782
|
const reason = failure.reason || failure.code || 'operation_id_conflict';
|
|
4617
|
-
nextRejected
|
|
4783
|
+
Object.defineProperty(nextRejected, operation.id, {
|
|
4784
|
+
value: {
|
|
4618
4785
|
id: operation.id,
|
|
4619
4786
|
operation: clone(operation),
|
|
4620
4787
|
status: 'rejected',
|
|
4621
4788
|
reason,
|
|
4622
4789
|
rejectedAt: timestamp,
|
|
4623
4790
|
response: rejectionResponse
|
|
4624
|
-
|
|
4791
|
+
},
|
|
4792
|
+
enumerable: true,
|
|
4793
|
+
configurable: true,
|
|
4794
|
+
writable: true
|
|
4795
|
+
});
|
|
4625
4796
|
rejectedResults.push({
|
|
4626
4797
|
id: operation.id,
|
|
4627
4798
|
operationId: operation.id,
|
|
@@ -4711,7 +4882,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4711
4882
|
const remoteState = remote?.state ?? remote?.data;
|
|
4712
4883
|
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
4713
4884
|
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
4714
|
-
if (typeof cloud?.bootstrap !== 'function') {
|
|
4885
|
+
if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
|
|
4715
4886
|
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4716
4887
|
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4717
4888
|
code: 'revision_gap',
|
|
@@ -4821,10 +4992,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4821
4992
|
|
|
4822
4993
|
const resetForSessionIdentity = (captured) => {
|
|
4823
4994
|
stopCloudSubscription();
|
|
4824
|
-
|
|
4825
|
-
ownerUid
|
|
4826
|
-
|
|
4827
|
-
|
|
4995
|
+
const sameOwner = Boolean(
|
|
4996
|
+
rtlNormalizeUid(workspace.ownerUid) &&
|
|
4997
|
+
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(captured?.uid)
|
|
4998
|
+
);
|
|
4999
|
+
const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
|
|
5000
|
+
workspace = sameOwner && epochIncreased
|
|
5001
|
+
? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
|
|
5002
|
+
: rtlEmptyDurableWorkspace({
|
|
5003
|
+
ownerUid: captured?.uid,
|
|
5004
|
+
workspaceEpoch: captured?.workspaceEpoch
|
|
5005
|
+
});
|
|
4828
5006
|
initialized = false;
|
|
4829
5007
|
hydrationRequired = true;
|
|
4830
5008
|
notify({type: 'session_changed'});
|
|
@@ -4857,6 +5035,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4857
5035
|
let adapterReadyExpected = false;
|
|
4858
5036
|
let firstCallbackResult = null;
|
|
4859
5037
|
let firstRootObservation = null;
|
|
5038
|
+
let latestRootObservationRevision = null;
|
|
4860
5039
|
let adapterCatchUpProof = null;
|
|
4861
5040
|
const settleReadyIfProven = () => {
|
|
4862
5041
|
if (!isCurrentRemoteReady(readyState) || readyState.settled ||
|
|
@@ -4873,6 +5052,31 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4873
5052
|
const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
|
|
4874
5053
|
? Number(proof.caughtUpToRevision)
|
|
4875
5054
|
: revision;
|
|
5055
|
+
if (revision < 0 || caughtUpToRevision < 0) {
|
|
5056
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5057
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5058
|
+
code: 'invalid_remote_ready_revision',
|
|
5059
|
+
reason: 'invalid_remote_ready_revision',
|
|
5060
|
+
message: 'invalid_remote_ready_revision'
|
|
5061
|
+
}));
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
5064
|
+
const observedRevision = Number.isSafeInteger(latestRootObservationRevision)
|
|
5065
|
+
? latestRootObservationRevision
|
|
5066
|
+
: baselineRevision;
|
|
5067
|
+
const requiredRevision = Math.max(baselineRevision, observedRevision);
|
|
5068
|
+
if (revision < requiredRevision || caughtUpToRevision < requiredRevision) {
|
|
5069
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5070
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5071
|
+
code: 'stale_remote_ready_revision',
|
|
5072
|
+
reason: 'stale_remote_ready_revision',
|
|
5073
|
+
message: 'stale_remote_ready_revision'
|
|
5074
|
+
}));
|
|
5075
|
+
return;
|
|
5076
|
+
}
|
|
5077
|
+
// A proof for a revision the root listener has not processed yet must
|
|
5078
|
+
// remain behind the barrier. A later root observation retries settling.
|
|
5079
|
+
if (revision > requiredRevision || caughtUpToRevision > requiredRevision) return;
|
|
4876
5080
|
readyState.resolve({
|
|
4877
5081
|
sessionKey: proof.sessionKey ??
|
|
4878
5082
|
context.sessionKey ??
|
|
@@ -4883,7 +5087,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4883
5087
|
caughtUpToRevision
|
|
4884
5088
|
});
|
|
4885
5089
|
};
|
|
4886
|
-
const handleRootObservation = async (result) => {
|
|
5090
|
+
const handleRootObservation = async (result, observedRevision) => {
|
|
4887
5091
|
if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
|
|
4888
5092
|
if (result?.stale === true || result?.reason === 'stale_session') return;
|
|
4889
5093
|
if (result?.success === false) {
|
|
@@ -4897,6 +5101,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4897
5101
|
return;
|
|
4898
5102
|
}
|
|
4899
5103
|
firstRootObservation = result || {success: true};
|
|
5104
|
+
if (Number.isSafeInteger(observedRevision) && observedRevision >= 0) {
|
|
5105
|
+
latestRootObservationRevision = Math.max(
|
|
5106
|
+
latestRootObservationRevision ?? 0,
|
|
5107
|
+
observedRevision
|
|
5108
|
+
);
|
|
5109
|
+
}
|
|
4900
5110
|
if (!adapterReadyExpected) {
|
|
4901
5111
|
adapterCatchUpProof = {
|
|
4902
5112
|
success: true,
|
|
@@ -4911,9 +5121,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4911
5121
|
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
4912
5122
|
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
4913
5123
|
}
|
|
5124
|
+
const observedRevisionValue = Number(rtlEnvelopePayload(remoteValue)?.revision);
|
|
5125
|
+
const observedRevision = Number.isSafeInteger(observedRevisionValue) && observedRevisionValue >= 0
|
|
5126
|
+
? observedRevisionValue
|
|
5127
|
+
: null;
|
|
4914
5128
|
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
4915
5129
|
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
4916
|
-
callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
|
|
5130
|
+
callbackResult.then((result) => handleRootObservation(result, observedRevision)).catch((error) => {
|
|
4917
5131
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
4918
5132
|
readyState.reject(error);
|
|
4919
5133
|
}
|
|
@@ -4931,7 +5145,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4931
5145
|
}
|
|
4932
5146
|
unsubscribeCloud = typeof disposer === 'function'
|
|
4933
5147
|
? disposer
|
|
4934
|
-
: (typeof disposer?.unsubscribe === 'function' ? disposer.unsubscribe : null);
|
|
5148
|
+
: (typeof disposer?.unsubscribe === 'function' ? () => disposer.unsubscribe() : null);
|
|
4935
5149
|
const onSubscriptionError = (error) => {
|
|
4936
5150
|
if (!isCurrentRemoteReady(readyState)) return;
|
|
4937
5151
|
stopCloudSubscription({settleReady: false});
|
|
@@ -4991,6 +5205,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4991
5205
|
const loadedBaseline = loaded?.remoteBaseline;
|
|
4992
5206
|
const committedRevision = Number(loadedBaseline?.revision);
|
|
4993
5207
|
const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
|
|
5208
|
+
const persistedHydrationRequired = loaded?.syncMeta?.hydrationRequired === true;
|
|
4994
5209
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
4995
5210
|
const identityChecked = rtlQuarantineOperations(
|
|
4996
5211
|
candidate,
|
|
@@ -5002,17 +5217,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5002
5217
|
const context = rtlSessionContext(captured, candidate, client);
|
|
5003
5218
|
|
|
5004
5219
|
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
5220
|
+
!persistedHydrationRequired &&
|
|
5005
5221
|
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
5006
5222
|
hasValidCommittedBaseline &&
|
|
5007
5223
|
candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
|
|
5008
5224
|
candidate.remoteBaseline.revision === committedRevision &&
|
|
5009
5225
|
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
5226
|
+
let obtainedFreshBaseline = canReuseCommitted;
|
|
5010
5227
|
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
5011
5228
|
// Do not carry untrusted resume state into a fresh hydration attempt.
|
|
5012
5229
|
// Only the bootstrap response may establish the next cursor/marker.
|
|
5013
5230
|
candidate.remoteBaseline.changeCursor = null;
|
|
5014
5231
|
candidate.syncMeta = {...candidate.syncMeta};
|
|
5015
|
-
['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding']
|
|
5232
|
+
['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding', 'changeCursorBinding']
|
|
5233
|
+
.forEach((key) => {
|
|
5016
5234
|
delete candidate.syncMeta[key];
|
|
5017
5235
|
});
|
|
5018
5236
|
let bootstrap;
|
|
@@ -5023,15 +5241,41 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5023
5241
|
));
|
|
5024
5242
|
} catch (error) {
|
|
5025
5243
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5244
|
+
if (persistedHydrationRequired) {
|
|
5245
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5246
|
+
await persist(candidate, captured);
|
|
5247
|
+
}
|
|
5026
5248
|
rethrowClassifiedCloudFailure(captured, error);
|
|
5027
5249
|
}
|
|
5028
5250
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5029
|
-
applyRemoteBaseline(candidate, bootstrap);
|
|
5251
|
+
const applied = applyRemoteBaseline(candidate, bootstrap);
|
|
5252
|
+
if (applied.stale) {
|
|
5253
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5254
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5255
|
+
workspace = candidate;
|
|
5256
|
+
initialized = true;
|
|
5257
|
+
hydrationRequired = true;
|
|
5258
|
+
notify({type: 'initialized'});
|
|
5259
|
+
return getSnapshot();
|
|
5260
|
+
}
|
|
5030
5261
|
delete candidate.syncMeta.terminalFailureBlock;
|
|
5262
|
+
clearPersistedHydrationBarrier(candidate);
|
|
5263
|
+
obtainedFreshBaseline = true;
|
|
5264
|
+
}
|
|
5265
|
+
|
|
5266
|
+
if (persistedHydrationRequired && !obtainedFreshBaseline) {
|
|
5267
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5268
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5269
|
+
workspace = candidate;
|
|
5270
|
+
initialized = true;
|
|
5271
|
+
hydrationRequired = true;
|
|
5272
|
+
notify({type: 'initialized'});
|
|
5273
|
+
return getSnapshot();
|
|
5031
5274
|
}
|
|
5032
5275
|
|
|
5033
5276
|
// Always persist the normalized durable shape before subscribing. This
|
|
5034
5277
|
// also makes legacy migration atomic from the engine's point of view.
|
|
5278
|
+
clearPersistedHydrationBarrier(candidate);
|
|
5035
5279
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5036
5280
|
workspace = candidate;
|
|
5037
5281
|
initialized = true;
|
|
@@ -5049,14 +5293,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5049
5293
|
startCloudSubscription(current, workspace);
|
|
5050
5294
|
return {success: true, tokenRefreshed: true};
|
|
5051
5295
|
}
|
|
5052
|
-
|
|
5053
|
-
workspace = rtlEmptyDurableWorkspace({
|
|
5054
|
-
ownerUid: current.uid,
|
|
5055
|
-
workspaceEpoch: current.workspaceEpoch
|
|
5056
|
-
});
|
|
5057
|
-
initialized = false;
|
|
5058
|
-
hydrationRequired = true;
|
|
5059
|
-
notify({type: 'session_changed'});
|
|
5296
|
+
resetForSessionIdentity(current);
|
|
5060
5297
|
return {success: true, sessionChanged: true};
|
|
5061
5298
|
}).catch((error) => {
|
|
5062
5299
|
logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
|
|
@@ -5388,14 +5625,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5388
5625
|
nextPending.push(makeRetryOperation(operation, result, null, timestamp));
|
|
5389
5626
|
} else if (result.status === 'rejected') {
|
|
5390
5627
|
completedOperations.push({operation, status: result.status});
|
|
5391
|
-
nextRejected
|
|
5628
|
+
Object.defineProperty(nextRejected, operation.id, {
|
|
5629
|
+
value: {
|
|
5392
5630
|
id: operation.id,
|
|
5393
5631
|
operation: clone(operation),
|
|
5394
5632
|
status: 'rejected',
|
|
5395
5633
|
reason: result.reason ?? result.error ?? result.code ?? 'rejected',
|
|
5396
5634
|
rejectedAt: timestamp,
|
|
5397
5635
|
response: rtlStripSessionTokens(result)
|
|
5398
|
-
|
|
5636
|
+
},
|
|
5637
|
+
enumerable: true,
|
|
5638
|
+
configurable: true,
|
|
5639
|
+
writable: true
|
|
5640
|
+
});
|
|
5399
5641
|
}
|
|
5400
5642
|
}
|
|
5401
5643
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
@@ -5930,8 +6172,8 @@ const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payl
|
|
|
5930
6172
|
'recordId', 'id'
|
|
5931
6173
|
]) ?? operation?.payload?.record?.id ?? null;
|
|
5932
6174
|
const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5933
|
-
'folderId', 'id'
|
|
5934
|
-
]) ?? operation?.payload?.folder?.id ?? null;
|
|
6175
|
+
'targetFolderId', 'folderId', 'id'
|
|
6176
|
+
]) ?? operation?.payload?.folder?.id ?? operation?.payload?.record?.folderId ?? null;
|
|
5935
6177
|
const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5936
6178
|
'trashEntryId', 'id'
|
|
5937
6179
|
]) ?? null;
|
|
@@ -6002,12 +6244,13 @@ const rtlHasInvalidPayloadValue = (value, depth = 0) => {
|
|
|
6002
6244
|
};
|
|
6003
6245
|
|
|
6004
6246
|
const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
6005
|
-
const rtlValidateIdList = (value, index, field, errors) => {
|
|
6247
|
+
const rtlValidateIdList = (value, index, field, errors, {opaqueGroup = false} = {}) => {
|
|
6006
6248
|
if (!Array.isArray(value)) {
|
|
6007
6249
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
6008
6250
|
return;
|
|
6009
6251
|
}
|
|
6010
|
-
|
|
6252
|
+
const isValid = opaqueGroup ? rtlIsSafeOpaqueGroupId : rtlIsSafeDocumentId;
|
|
6253
|
+
if (value.some((id) => !isValid(id))) {
|
|
6011
6254
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
6012
6255
|
}
|
|
6013
6256
|
};
|
|
@@ -6027,8 +6270,13 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
6027
6270
|
break;
|
|
6028
6271
|
case OPERATION_TYPES.RECORD_MOVE:
|
|
6029
6272
|
if (!recordId) errors.push(`operation_${index}_missing_record_id`);
|
|
6030
|
-
|
|
6031
|
-
|
|
6273
|
+
{
|
|
6274
|
+
const targetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6275
|
+
if (targetFolderId === undefined || targetFolderId === null) {
|
|
6276
|
+
errors.push(`operation_${index}_missing_target_folder_id`);
|
|
6277
|
+
} else if (!rtlIsSafeDocumentId(targetFolderId)) {
|
|
6278
|
+
errors.push(`operation_${index}_invalid_target_folder_id`);
|
|
6279
|
+
}
|
|
6032
6280
|
}
|
|
6033
6281
|
break;
|
|
6034
6282
|
case OPERATION_TYPES.RECORD_DELETE:
|
|
@@ -6076,14 +6324,21 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
6076
6324
|
rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
|
|
6077
6325
|
break;
|
|
6078
6326
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
6079
|
-
rtlValidateIdList(
|
|
6327
|
+
rtlValidateIdList(
|
|
6328
|
+
payload.groupOrder || payload.order,
|
|
6329
|
+
index,
|
|
6330
|
+
'group_order',
|
|
6331
|
+
errors,
|
|
6332
|
+
{opaqueGroup: true}
|
|
6333
|
+
);
|
|
6080
6334
|
break;
|
|
6081
6335
|
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
6082
6336
|
rtlValidateIdList(
|
|
6083
6337
|
payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
|
|
6084
6338
|
index,
|
|
6085
6339
|
'expanded_groups',
|
|
6086
|
-
errors
|
|
6340
|
+
errors,
|
|
6341
|
+
{opaqueGroup: true}
|
|
6087
6342
|
);
|
|
6088
6343
|
break;
|
|
6089
6344
|
case OPERATION_TYPES.SETTINGS_UPDATE:
|
|
@@ -6503,8 +6758,17 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6503
6758
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
6504
6759
|
break;
|
|
6505
6760
|
}
|
|
6761
|
+
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6762
|
+
if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
|
|
6763
|
+
reason = 'missing_target_folder_id';
|
|
6764
|
+
break;
|
|
6765
|
+
}
|
|
6766
|
+
if (!rtlIsSafeDocumentId(rawTargetFolderId)) {
|
|
6767
|
+
reason = 'invalid_document_id';
|
|
6768
|
+
break;
|
|
6769
|
+
}
|
|
6506
6770
|
const sourceFolderId = safeFolderId(existing.folderId);
|
|
6507
|
-
const targetFolderId =
|
|
6771
|
+
const targetFolderId = rawTargetFolderId;
|
|
6508
6772
|
applied = rtlApplyOperationToPartialDocuments({
|
|
6509
6773
|
...nextDocuments,
|
|
6510
6774
|
operation: {...operation, payload: {...payload, targetFolderId}},
|
package/src/protocol.js
CHANGED
|
@@ -118,6 +118,7 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
|
|
|
118
118
|
['bulk_job_in_progress', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
119
119
|
['bootstrap_session_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
120
120
|
['daily_bootstrap_read_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
121
|
+
['global_bootstrap_read_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
121
122
|
['rate_limited', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
122
123
|
['too_many_requests', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
123
124
|
['network_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
@@ -131,6 +132,9 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
|
|
|
131
132
|
['cache_ahead', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
132
133
|
['changed_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
133
134
|
['root_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
135
|
+
['firestore_v2_root_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
136
|
+
['firestore_v2_root_revision_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
137
|
+
['bootstrap_missing_root', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
134
138
|
['stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
|
|
135
139
|
['recordtimelabel_stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
|
|
136
140
|
['auth_context_changed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
|
|
@@ -160,6 +164,11 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
|
|
|
160
164
|
['lifecycle_generation_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
|
|
161
165
|
['record_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
|
|
162
166
|
['folder_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
|
|
167
|
+
// Snapshot workers consume this same classifier as HTTP clients. These
|
|
168
|
+
// states cannot become valid by retrying the immutable queued job.
|
|
169
|
+
['snapshot_upload_binding_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
|
|
170
|
+
['snapshot_upload_changed_after_queue', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
|
|
171
|
+
['invalid_snapshot_job_state', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
|
|
163
172
|
...[...OPERATION_CONFLICT_CODES].map((code) => [
|
|
164
173
|
code,
|
|
165
174
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL
|