@recordtimelabel/core 0.6.4 → 0.6.6
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 +362 -57
- 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.6`; 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.6"
|
|
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.6';
|
|
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;
|
|
@@ -1145,6 +1145,10 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1145
1145
|
originalRecordIndex: Number.isInteger(payload.originalRecordIndex)
|
|
1146
1146
|
? payload.originalRecordIndex
|
|
1147
1147
|
: Math.max(0, Number(existingEntry?.index || 0)),
|
|
1148
|
+
originalGroupId: String(payload.originalGroupId || '').trim(),
|
|
1149
|
+
originalGroupOrderIndex: Number.isInteger(payload.originalGroupOrderIndex)
|
|
1150
|
+
? payload.originalGroupOrderIndex
|
|
1151
|
+
: -1,
|
|
1148
1152
|
lifecycleGeneration,
|
|
1149
1153
|
deletedAt,
|
|
1150
1154
|
purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
|
|
@@ -1206,6 +1210,16 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1206
1210
|
);
|
|
1207
1211
|
targetRecords.splice(restoreIndex, 0, restoredRecord);
|
|
1208
1212
|
nextState.records[targetFolderId] = targetRecords;
|
|
1213
|
+
const originalGroupId = String(trashEntry.originalGroupId || '').trim();
|
|
1214
|
+
const originalGroupOrderIndex = Number.isInteger(trashEntry.originalGroupOrderIndex)
|
|
1215
|
+
? trashEntry.originalGroupOrderIndex
|
|
1216
|
+
: -1;
|
|
1217
|
+
if (originalGroupId && originalGroupOrderIndex >= 0 &&
|
|
1218
|
+
!nextState.groupOrder.includes(originalGroupId)) {
|
|
1219
|
+
const groupOrder = [...nextState.groupOrder];
|
|
1220
|
+
groupOrder.splice(Math.min(originalGroupOrderIndex, groupOrder.length), 0, originalGroupId);
|
|
1221
|
+
nextState.groupOrder = groupOrder;
|
|
1222
|
+
}
|
|
1209
1223
|
delete nextState.trashEntries[trashEntryId];
|
|
1210
1224
|
delete nextState.deletedRecordTombstones[recordId];
|
|
1211
1225
|
break;
|
|
@@ -3288,20 +3302,20 @@ const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
|
|
|
3288
3302
|
!Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
|
|
3289
3303
|
!Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
|
|
3290
3304
|
!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
|
-
)) {
|
|
3305
|
+
!rtlRawNonNegativeSafeInteger(baseline.revision)) {
|
|
3301
3306
|
return false;
|
|
3302
3307
|
}
|
|
3303
3308
|
if (loaded.syncMeta !== undefined &&
|
|
3304
3309
|
!rtlDurableIsObject(loaded.syncMeta)) return false;
|
|
3310
|
+
if (typeof baseline.changeCursor !== 'string' ||
|
|
3311
|
+
!rtlResumeIdentityMatches(
|
|
3312
|
+
loaded.syncMeta?.changeCursorBinding,
|
|
3313
|
+
captured,
|
|
3314
|
+
ownerUid,
|
|
3315
|
+
loaded.workspaceEpoch
|
|
3316
|
+
)) {
|
|
3317
|
+
return false;
|
|
3318
|
+
}
|
|
3305
3319
|
const resumeMarker = loaded.syncMeta?.resumeMarker ??
|
|
3306
3320
|
loaded.syncMeta?.resume ??
|
|
3307
3321
|
loaded.syncMeta?.bootstrapMarker;
|
|
@@ -3358,11 +3372,54 @@ const rtlStripSessionTokens = (value) => {
|
|
|
3358
3372
|
const result = {};
|
|
3359
3373
|
Object.entries(value).forEach(([key, entry]) => {
|
|
3360
3374
|
if (rtlIsCredentialKey(key)) return;
|
|
3361
|
-
result
|
|
3375
|
+
Object.defineProperty(result, key, {
|
|
3376
|
+
value: rtlStripSessionTokens(entry),
|
|
3377
|
+
enumerable: true,
|
|
3378
|
+
configurable: true,
|
|
3379
|
+
writable: true
|
|
3380
|
+
});
|
|
3362
3381
|
});
|
|
3363
3382
|
return result;
|
|
3364
3383
|
};
|
|
3365
3384
|
|
|
3385
|
+
// Domain state and operation payloads are opaque to Core. They may legitimately
|
|
3386
|
+
// contain fields such as `designToken` or `session` that are part of the user's
|
|
3387
|
+
// data, so the session-secret scrubber is limited to Core-owned envelopes.
|
|
3388
|
+
const rtlSanitizeDurableOperation = (operation) => {
|
|
3389
|
+
const sanitized = rtlStripSessionTokens(operation);
|
|
3390
|
+
if (rtlDurableIsObject(operation) &&
|
|
3391
|
+
Object.prototype.hasOwnProperty.call(operation, 'payload')) {
|
|
3392
|
+
sanitized.payload = clone(operation.payload);
|
|
3393
|
+
}
|
|
3394
|
+
return sanitized;
|
|
3395
|
+
};
|
|
3396
|
+
|
|
3397
|
+
const rtlSanitizeDurableWorkspace = (workspace) => {
|
|
3398
|
+
const sanitized = rtlStripSessionTokens(workspace);
|
|
3399
|
+
if (!rtlDurableIsObject(workspace)) return sanitized;
|
|
3400
|
+
if (rtlDurableIsObject(workspace.remoteBaseline) &&
|
|
3401
|
+
Object.prototype.hasOwnProperty.call(workspace.remoteBaseline, 'state')) {
|
|
3402
|
+
sanitized.remoteBaseline = {
|
|
3403
|
+
...sanitized.remoteBaseline,
|
|
3404
|
+
state: clone(workspace.remoteBaseline.state)
|
|
3405
|
+
};
|
|
3406
|
+
}
|
|
3407
|
+
if (Array.isArray(workspace.pendingOperations)) {
|
|
3408
|
+
sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
|
|
3409
|
+
}
|
|
3410
|
+
if (rtlDurableIsObject(workspace.rejectedOperations)) {
|
|
3411
|
+
sanitized.rejectedOperations = {...sanitized.rejectedOperations};
|
|
3412
|
+
Object.entries(workspace.rejectedOperations).forEach(([id, entry]) => {
|
|
3413
|
+
if (!rtlDurableIsObject(entry) || !rtlDurableIsObject(entry.operation)) return;
|
|
3414
|
+
sanitized.rejectedOperations[id] = {
|
|
3415
|
+
...sanitized.rejectedOperations[id],
|
|
3416
|
+
operation: rtlSanitizeDurableOperation(entry.operation)
|
|
3417
|
+
};
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
return sanitized;
|
|
3421
|
+
};
|
|
3422
|
+
|
|
3366
3423
|
const rtlNormalizeOperation = (operation = {}, {
|
|
3367
3424
|
client,
|
|
3368
3425
|
clientId,
|
|
@@ -3389,7 +3446,7 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3389
3446
|
...rtlStripSessionTokens(input),
|
|
3390
3447
|
// Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
|
|
3391
3448
|
// invalid whitespace or hostile ID instead of receiving a silently rewritten one.
|
|
3392
|
-
id:
|
|
3449
|
+
id: input.id === null || input.id === undefined ? generatedId : input.id,
|
|
3393
3450
|
type: typeof input.type === 'string' ? input.type.trim() : input.type,
|
|
3394
3451
|
payload: normalizedPayload,
|
|
3395
3452
|
clientId: resolvedClientId,
|
|
@@ -3441,6 +3498,72 @@ const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) =>
|
|
|
3441
3498
|
syncMeta: {}
|
|
3442
3499
|
});
|
|
3443
3500
|
|
|
3501
|
+
const RTL_BOOTSTRAP_RESUME_META_KEYS = [
|
|
3502
|
+
'resumeMarker',
|
|
3503
|
+
'resume',
|
|
3504
|
+
'bootstrapMarker',
|
|
3505
|
+
'authSessionBinding',
|
|
3506
|
+
'changeCursorBinding'
|
|
3507
|
+
];
|
|
3508
|
+
|
|
3509
|
+
const rtlForceOperationIdentity = (operation, ownerUid, workspaceEpoch, options = {}) => ({
|
|
3510
|
+
...rtlNormalizePendingOperation(operation, {
|
|
3511
|
+
...options,
|
|
3512
|
+
ownerUid,
|
|
3513
|
+
workspaceEpoch
|
|
3514
|
+
}),
|
|
3515
|
+
ownerUid: rtlNormalizeUid(ownerUid),
|
|
3516
|
+
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0)
|
|
3517
|
+
});
|
|
3518
|
+
|
|
3519
|
+
const rtlClearBootstrapResumeMeta = (syncMeta) => {
|
|
3520
|
+
const next = rtlDurableIsObject(syncMeta) ? {...syncMeta} : {};
|
|
3521
|
+
RTL_BOOTSTRAP_RESUME_META_KEYS.forEach((key) => {
|
|
3522
|
+
delete next[key];
|
|
3523
|
+
});
|
|
3524
|
+
return next;
|
|
3525
|
+
};
|
|
3526
|
+
|
|
3527
|
+
const rtlRebindSameOwnerWorkspaceEpoch = (workspace, captured, now) => {
|
|
3528
|
+
const ownerUid = rtlNormalizeUid(captured?.uid);
|
|
3529
|
+
const workspaceEpoch = rtlNormalizeWorkspaceEpoch(captured?.workspaceEpoch, 0);
|
|
3530
|
+
const next = clone(workspace);
|
|
3531
|
+
next.ownerUid = ownerUid;
|
|
3532
|
+
next.workspaceEpoch = workspaceEpoch;
|
|
3533
|
+
next.pendingOperations = toArray(next.pendingOperations).map((operation) => (
|
|
3534
|
+
rtlForceOperationIdentity(operation, ownerUid, workspaceEpoch, {now})
|
|
3535
|
+
));
|
|
3536
|
+
if (rtlDurableIsObject(next.rejectedOperations)) {
|
|
3537
|
+
const rejected = {};
|
|
3538
|
+
Object.entries(next.rejectedOperations).forEach(([id, entry]) => {
|
|
3539
|
+
const value = rtlDurableIsObject(entry) ? {...entry} : entry;
|
|
3540
|
+
if (rtlDurableIsObject(value) && value.operation) {
|
|
3541
|
+
value.operation = rtlForceOperationIdentity(
|
|
3542
|
+
value.operation,
|
|
3543
|
+
ownerUid,
|
|
3544
|
+
workspaceEpoch,
|
|
3545
|
+
{now}
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
Object.defineProperty(rejected, id, {
|
|
3549
|
+
value,
|
|
3550
|
+
enumerable: true,
|
|
3551
|
+
configurable: true,
|
|
3552
|
+
writable: true
|
|
3553
|
+
});
|
|
3554
|
+
});
|
|
3555
|
+
next.rejectedOperations = rejected;
|
|
3556
|
+
}
|
|
3557
|
+
next.remoteBaseline = {
|
|
3558
|
+
...(rtlDurableIsObject(next.remoteBaseline)
|
|
3559
|
+
? next.remoteBaseline
|
|
3560
|
+
: rtlNormalizeRemoteBaseline({})),
|
|
3561
|
+
changeCursor: null
|
|
3562
|
+
};
|
|
3563
|
+
next.syncMeta = rtlClearBootstrapResumeMeta(next.syncMeta);
|
|
3564
|
+
return next;
|
|
3565
|
+
};
|
|
3566
|
+
|
|
3444
3567
|
const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
3445
3568
|
const source = rtlDurableIsObject(input) ? input : {};
|
|
3446
3569
|
const legacy = !source.remoteBaseline && (
|
|
@@ -3479,7 +3602,12 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3479
3602
|
Object.entries(rejectedSource).forEach(([rawId, value]) => {
|
|
3480
3603
|
const id = normalizeId(rawId || value?.id || value?.operationId);
|
|
3481
3604
|
if (!id) return;
|
|
3482
|
-
rejectedOperations
|
|
3605
|
+
Object.defineProperty(rejectedOperations, id, {
|
|
3606
|
+
value: clone(value && typeof value === 'object' ? value : {reason: value}),
|
|
3607
|
+
enumerable: true,
|
|
3608
|
+
configurable: true,
|
|
3609
|
+
writable: true
|
|
3610
|
+
});
|
|
3483
3611
|
if (rejectedOperations[id].operation) {
|
|
3484
3612
|
rejectedOperations[id].operation = rtlNormalizePendingOperation(
|
|
3485
3613
|
rejectedOperations[id].operation,
|
|
@@ -3506,11 +3634,13 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3506
3634
|
const rtlOperationIdentityReason = (operation, workspace) => {
|
|
3507
3635
|
const candidateIds = [
|
|
3508
3636
|
operation?.id,
|
|
3637
|
+
operation?.payload?.id,
|
|
3509
3638
|
operation?.payload?.recordId,
|
|
3510
3639
|
operation?.payload?.folderId,
|
|
3511
3640
|
operation?.payload?.targetFolderId,
|
|
3512
3641
|
operation?.payload?.trashEntryId,
|
|
3513
3642
|
operation?.payload?.record?.id,
|
|
3643
|
+
operation?.payload?.record?.folderId,
|
|
3514
3644
|
operation?.payload?.folder?.id,
|
|
3515
3645
|
...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
|
|
3516
3646
|
...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
|
|
@@ -3651,7 +3781,8 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3651
3781
|
}
|
|
3652
3782
|
rejectedCount += 1;
|
|
3653
3783
|
const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
|
|
3654
|
-
rejected
|
|
3784
|
+
Object.defineProperty(rejected, rejectedId, {
|
|
3785
|
+
value: {
|
|
3655
3786
|
id: rejectedId,
|
|
3656
3787
|
operation: clone(operation),
|
|
3657
3788
|
status: 'rejected',
|
|
@@ -3666,7 +3797,11 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3666
3797
|
),
|
|
3667
3798
|
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
|
|
3668
3799
|
}
|
|
3669
|
-
|
|
3800
|
+
},
|
|
3801
|
+
enumerable: true,
|
|
3802
|
+
configurable: true,
|
|
3803
|
+
writable: true
|
|
3804
|
+
});
|
|
3670
3805
|
});
|
|
3671
3806
|
return {accepted, rejected, rejectedCount, deduplicatedCount};
|
|
3672
3807
|
};
|
|
@@ -3741,8 +3876,6 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3741
3876
|
return candidate;
|
|
3742
3877
|
};
|
|
3743
3878
|
if (value && typeof value === 'object') {
|
|
3744
|
-
if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
|
|
3745
|
-
if (value.baseline) return normalizeRevisionAlias(value.baseline);
|
|
3746
3879
|
// A canonical failure may carry an authoritative baseline for an
|
|
3747
3880
|
// operation-id conflict. It must win over compatibility state fields
|
|
3748
3881
|
// left beside `failure`; otherwise a stale outer state can be applied
|
|
@@ -3751,6 +3884,8 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3751
3884
|
const nested = rtlEnvelopePayload(value.failure);
|
|
3752
3885
|
if (nested) return nested;
|
|
3753
3886
|
}
|
|
3887
|
+
if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
|
|
3888
|
+
if (value.baseline) return normalizeRevisionAlias(value.baseline);
|
|
3754
3889
|
if (
|
|
3755
3890
|
value.state !== undefined ||
|
|
3756
3891
|
value.data !== undefined ||
|
|
@@ -3980,17 +4115,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3980
4115
|
const persist = async (candidate, captured) => {
|
|
3981
4116
|
if (!(await isCurrent(captured))) return false;
|
|
3982
4117
|
const cursor = candidate?.remoteBaseline?.changeCursor;
|
|
4118
|
+
candidate.syncMeta = rtlDurableIsObject(candidate.syncMeta)
|
|
4119
|
+
? {...candidate.syncMeta}
|
|
4120
|
+
: {};
|
|
3983
4121
|
if (cursor !== null && cursor !== undefined) {
|
|
3984
|
-
|
|
3985
|
-
candidate.remoteBaseline.changeCursor = {
|
|
3986
|
-
...durableCursor,
|
|
4122
|
+
candidate.syncMeta.changeCursorBinding = {
|
|
3987
4123
|
ownerUid: captured?.uid ?? null,
|
|
3988
4124
|
uid: captured?.uid ?? null,
|
|
3989
4125
|
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
|
|
3990
4126
|
authSessionBinding: captured?.authSessionBinding ?? null
|
|
3991
4127
|
};
|
|
4128
|
+
} else {
|
|
4129
|
+
delete candidate.syncMeta.changeCursorBinding;
|
|
3992
4130
|
}
|
|
3993
|
-
const durableCandidate =
|
|
4131
|
+
const durableCandidate = rtlSanitizeDurableWorkspace(candidate);
|
|
3994
4132
|
// The second argument is an optional adapter-side fence. The public
|
|
3995
4133
|
// StoragePort remains compatible with save(workspace); adapters that can
|
|
3996
4134
|
// enforce an atomic session/epoch check should consume this context before
|
|
@@ -4284,7 +4422,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4284
4422
|
const epochMismatch = Boolean(
|
|
4285
4423
|
captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
|
|
4286
4424
|
);
|
|
4287
|
-
if (ownerMismatch
|
|
4425
|
+
if (ownerMismatch) {
|
|
4426
|
+
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
4427
|
+
}
|
|
4428
|
+
if (epochMismatch) {
|
|
4429
|
+
if (
|
|
4430
|
+
sourceOwnerUid &&
|
|
4431
|
+
sourceOwnerUid === currentUid &&
|
|
4432
|
+
Number(currentEpoch) > Number(loadedWorkspace.workspaceEpoch)
|
|
4433
|
+
) {
|
|
4434
|
+
return rtlRebindSameOwnerWorkspaceEpoch(loadedWorkspace, captured, now);
|
|
4435
|
+
}
|
|
4288
4436
|
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
4289
4437
|
}
|
|
4290
4438
|
const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
|
|
@@ -4358,6 +4506,30 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4358
4506
|
...extra
|
|
4359
4507
|
});
|
|
4360
4508
|
|
|
4509
|
+
const persistHydrationBarrier = async (captured, extraMeta = {}) => {
|
|
4510
|
+
hydrationRequired = true;
|
|
4511
|
+
const candidate = clone(workspace);
|
|
4512
|
+
candidate.syncMeta = {
|
|
4513
|
+
...candidate.syncMeta,
|
|
4514
|
+
hydrationRequired: true,
|
|
4515
|
+
...extraMeta
|
|
4516
|
+
};
|
|
4517
|
+
if (!(await persist(candidate, captured))) return false;
|
|
4518
|
+
workspace = candidate;
|
|
4519
|
+
return true;
|
|
4520
|
+
};
|
|
4521
|
+
|
|
4522
|
+
const clearPersistedHydrationBarrier = (candidate) => {
|
|
4523
|
+
if (!rtlDurableIsObject(candidate?.syncMeta) ||
|
|
4524
|
+
!Object.prototype.hasOwnProperty.call(candidate.syncMeta, 'hydrationRequired')) {
|
|
4525
|
+
return candidate;
|
|
4526
|
+
}
|
|
4527
|
+
const nextMeta = {...candidate.syncMeta};
|
|
4528
|
+
delete nextMeta.hydrationRequired;
|
|
4529
|
+
candidate.syncMeta = nextMeta;
|
|
4530
|
+
return candidate;
|
|
4531
|
+
};
|
|
4532
|
+
|
|
4361
4533
|
const persistTerminalFailureBlock = async ({
|
|
4362
4534
|
failure,
|
|
4363
4535
|
error,
|
|
@@ -4393,6 +4565,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4393
4565
|
identityRejectedCount
|
|
4394
4566
|
}) => {
|
|
4395
4567
|
hydrationRequired = true;
|
|
4568
|
+
if (!(await persistHydrationBarrier(captured, {
|
|
4569
|
+
lastSyncAttemptAt: timestamp,
|
|
4570
|
+
lastSyncFailure: clone(failure),
|
|
4571
|
+
lastSyncError: failure.message || failure.code || 'bootstrap-required'
|
|
4572
|
+
}))) {
|
|
4573
|
+
return {success: false, reason: 'stale_session'};
|
|
4574
|
+
}
|
|
4396
4575
|
if (typeof cloud?.bootstrap !== 'function') {
|
|
4397
4576
|
return failureOutcome(failure, error, {
|
|
4398
4577
|
bootstrapRequired: true,
|
|
@@ -4431,6 +4610,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4431
4610
|
candidate.syncMeta.lastSyncFailure = clone(failure);
|
|
4432
4611
|
candidate.syncMeta.lastSyncAttemptAt = timestamp;
|
|
4433
4612
|
candidate.syncMeta.lastSyncError = null;
|
|
4613
|
+
clearPersistedHydrationBarrier(candidate);
|
|
4434
4614
|
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4435
4615
|
workspace = candidate;
|
|
4436
4616
|
hydrationRequired = false;
|
|
@@ -4614,14 +4794,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4614
4794
|
const completedOperations = [];
|
|
4615
4795
|
ready.forEach((operation) => {
|
|
4616
4796
|
const reason = failure.reason || failure.code || 'operation_id_conflict';
|
|
4617
|
-
nextRejected
|
|
4797
|
+
Object.defineProperty(nextRejected, operation.id, {
|
|
4798
|
+
value: {
|
|
4618
4799
|
id: operation.id,
|
|
4619
4800
|
operation: clone(operation),
|
|
4620
4801
|
status: 'rejected',
|
|
4621
4802
|
reason,
|
|
4622
4803
|
rejectedAt: timestamp,
|
|
4623
4804
|
response: rejectionResponse
|
|
4624
|
-
|
|
4805
|
+
},
|
|
4806
|
+
enumerable: true,
|
|
4807
|
+
configurable: true,
|
|
4808
|
+
writable: true
|
|
4809
|
+
});
|
|
4625
4810
|
rejectedResults.push({
|
|
4626
4811
|
id: operation.id,
|
|
4627
4812
|
operationId: operation.id,
|
|
@@ -4711,7 +4896,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4711
4896
|
const remoteState = remote?.state ?? remote?.data;
|
|
4712
4897
|
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
4713
4898
|
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
4714
|
-
if (typeof cloud?.bootstrap !== 'function') {
|
|
4899
|
+
if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
|
|
4715
4900
|
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4716
4901
|
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4717
4902
|
code: 'revision_gap',
|
|
@@ -4821,10 +5006,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4821
5006
|
|
|
4822
5007
|
const resetForSessionIdentity = (captured) => {
|
|
4823
5008
|
stopCloudSubscription();
|
|
4824
|
-
|
|
4825
|
-
ownerUid
|
|
4826
|
-
|
|
4827
|
-
|
|
5009
|
+
const sameOwner = Boolean(
|
|
5010
|
+
rtlNormalizeUid(workspace.ownerUid) &&
|
|
5011
|
+
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(captured?.uid)
|
|
5012
|
+
);
|
|
5013
|
+
const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
|
|
5014
|
+
workspace = sameOwner && epochIncreased
|
|
5015
|
+
? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
|
|
5016
|
+
: rtlEmptyDurableWorkspace({
|
|
5017
|
+
ownerUid: captured?.uid,
|
|
5018
|
+
workspaceEpoch: captured?.workspaceEpoch
|
|
5019
|
+
});
|
|
4828
5020
|
initialized = false;
|
|
4829
5021
|
hydrationRequired = true;
|
|
4830
5022
|
notify({type: 'session_changed'});
|
|
@@ -4857,6 +5049,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4857
5049
|
let adapterReadyExpected = false;
|
|
4858
5050
|
let firstCallbackResult = null;
|
|
4859
5051
|
let firstRootObservation = null;
|
|
5052
|
+
let latestRootObservationRevision = null;
|
|
4860
5053
|
let adapterCatchUpProof = null;
|
|
4861
5054
|
const settleReadyIfProven = () => {
|
|
4862
5055
|
if (!isCurrentRemoteReady(readyState) || readyState.settled ||
|
|
@@ -4873,6 +5066,31 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4873
5066
|
const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
|
|
4874
5067
|
? Number(proof.caughtUpToRevision)
|
|
4875
5068
|
: revision;
|
|
5069
|
+
if (revision < 0 || caughtUpToRevision < 0) {
|
|
5070
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5071
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5072
|
+
code: 'invalid_remote_ready_revision',
|
|
5073
|
+
reason: 'invalid_remote_ready_revision',
|
|
5074
|
+
message: 'invalid_remote_ready_revision'
|
|
5075
|
+
}));
|
|
5076
|
+
return;
|
|
5077
|
+
}
|
|
5078
|
+
const observedRevision = Number.isSafeInteger(latestRootObservationRevision)
|
|
5079
|
+
? latestRootObservationRevision
|
|
5080
|
+
: baselineRevision;
|
|
5081
|
+
const requiredRevision = Math.max(baselineRevision, observedRevision);
|
|
5082
|
+
if (revision < requiredRevision || caughtUpToRevision < requiredRevision) {
|
|
5083
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5084
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5085
|
+
code: 'stale_remote_ready_revision',
|
|
5086
|
+
reason: 'stale_remote_ready_revision',
|
|
5087
|
+
message: 'stale_remote_ready_revision'
|
|
5088
|
+
}));
|
|
5089
|
+
return;
|
|
5090
|
+
}
|
|
5091
|
+
// A proof for a revision the root listener has not processed yet must
|
|
5092
|
+
// remain behind the barrier. A later root observation retries settling.
|
|
5093
|
+
if (revision > requiredRevision || caughtUpToRevision > requiredRevision) return;
|
|
4876
5094
|
readyState.resolve({
|
|
4877
5095
|
sessionKey: proof.sessionKey ??
|
|
4878
5096
|
context.sessionKey ??
|
|
@@ -4883,7 +5101,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4883
5101
|
caughtUpToRevision
|
|
4884
5102
|
});
|
|
4885
5103
|
};
|
|
4886
|
-
const handleRootObservation = async (result) => {
|
|
5104
|
+
const handleRootObservation = async (result, observedRevision) => {
|
|
4887
5105
|
if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
|
|
4888
5106
|
if (result?.stale === true || result?.reason === 'stale_session') return;
|
|
4889
5107
|
if (result?.success === false) {
|
|
@@ -4897,6 +5115,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4897
5115
|
return;
|
|
4898
5116
|
}
|
|
4899
5117
|
firstRootObservation = result || {success: true};
|
|
5118
|
+
if (Number.isSafeInteger(observedRevision) && observedRevision >= 0) {
|
|
5119
|
+
latestRootObservationRevision = Math.max(
|
|
5120
|
+
latestRootObservationRevision ?? 0,
|
|
5121
|
+
observedRevision
|
|
5122
|
+
);
|
|
5123
|
+
}
|
|
4900
5124
|
if (!adapterReadyExpected) {
|
|
4901
5125
|
adapterCatchUpProof = {
|
|
4902
5126
|
success: true,
|
|
@@ -4911,9 +5135,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4911
5135
|
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
4912
5136
|
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
4913
5137
|
}
|
|
5138
|
+
const observedRevisionValue = Number(rtlEnvelopePayload(remoteValue)?.revision);
|
|
5139
|
+
const observedRevision = Number.isSafeInteger(observedRevisionValue) && observedRevisionValue >= 0
|
|
5140
|
+
? observedRevisionValue
|
|
5141
|
+
: null;
|
|
4914
5142
|
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
4915
5143
|
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
4916
|
-
callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
|
|
5144
|
+
callbackResult.then((result) => handleRootObservation(result, observedRevision)).catch((error) => {
|
|
4917
5145
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
4918
5146
|
readyState.reject(error);
|
|
4919
5147
|
}
|
|
@@ -4931,7 +5159,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4931
5159
|
}
|
|
4932
5160
|
unsubscribeCloud = typeof disposer === 'function'
|
|
4933
5161
|
? disposer
|
|
4934
|
-
: (typeof disposer?.unsubscribe === 'function' ? disposer.unsubscribe : null);
|
|
5162
|
+
: (typeof disposer?.unsubscribe === 'function' ? () => disposer.unsubscribe() : null);
|
|
4935
5163
|
const onSubscriptionError = (error) => {
|
|
4936
5164
|
if (!isCurrentRemoteReady(readyState)) return;
|
|
4937
5165
|
stopCloudSubscription({settleReady: false});
|
|
@@ -4991,6 +5219,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4991
5219
|
const loadedBaseline = loaded?.remoteBaseline;
|
|
4992
5220
|
const committedRevision = Number(loadedBaseline?.revision);
|
|
4993
5221
|
const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
|
|
5222
|
+
const persistedHydrationRequired = loaded?.syncMeta?.hydrationRequired === true;
|
|
4994
5223
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
4995
5224
|
const identityChecked = rtlQuarantineOperations(
|
|
4996
5225
|
candidate,
|
|
@@ -5002,17 +5231,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5002
5231
|
const context = rtlSessionContext(captured, candidate, client);
|
|
5003
5232
|
|
|
5004
5233
|
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
5234
|
+
!persistedHydrationRequired &&
|
|
5005
5235
|
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
5006
5236
|
hasValidCommittedBaseline &&
|
|
5007
5237
|
candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
|
|
5008
5238
|
candidate.remoteBaseline.revision === committedRevision &&
|
|
5009
5239
|
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
5240
|
+
let obtainedFreshBaseline = canReuseCommitted;
|
|
5010
5241
|
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
5011
5242
|
// Do not carry untrusted resume state into a fresh hydration attempt.
|
|
5012
5243
|
// Only the bootstrap response may establish the next cursor/marker.
|
|
5013
5244
|
candidate.remoteBaseline.changeCursor = null;
|
|
5014
5245
|
candidate.syncMeta = {...candidate.syncMeta};
|
|
5015
|
-
['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding']
|
|
5246
|
+
['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding', 'changeCursorBinding']
|
|
5247
|
+
.forEach((key) => {
|
|
5016
5248
|
delete candidate.syncMeta[key];
|
|
5017
5249
|
});
|
|
5018
5250
|
let bootstrap;
|
|
@@ -5023,15 +5255,41 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5023
5255
|
));
|
|
5024
5256
|
} catch (error) {
|
|
5025
5257
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5258
|
+
if (persistedHydrationRequired) {
|
|
5259
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5260
|
+
await persist(candidate, captured);
|
|
5261
|
+
}
|
|
5026
5262
|
rethrowClassifiedCloudFailure(captured, error);
|
|
5027
5263
|
}
|
|
5028
5264
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5029
|
-
applyRemoteBaseline(candidate, bootstrap);
|
|
5265
|
+
const applied = applyRemoteBaseline(candidate, bootstrap);
|
|
5266
|
+
if (applied.stale) {
|
|
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();
|
|
5274
|
+
}
|
|
5030
5275
|
delete candidate.syncMeta.terminalFailureBlock;
|
|
5276
|
+
clearPersistedHydrationBarrier(candidate);
|
|
5277
|
+
obtainedFreshBaseline = true;
|
|
5278
|
+
}
|
|
5279
|
+
|
|
5280
|
+
if (persistedHydrationRequired && !obtainedFreshBaseline) {
|
|
5281
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5282
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5283
|
+
workspace = candidate;
|
|
5284
|
+
initialized = true;
|
|
5285
|
+
hydrationRequired = true;
|
|
5286
|
+
notify({type: 'initialized'});
|
|
5287
|
+
return getSnapshot();
|
|
5031
5288
|
}
|
|
5032
5289
|
|
|
5033
5290
|
// Always persist the normalized durable shape before subscribing. This
|
|
5034
5291
|
// also makes legacy migration atomic from the engine's point of view.
|
|
5292
|
+
clearPersistedHydrationBarrier(candidate);
|
|
5035
5293
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5036
5294
|
workspace = candidate;
|
|
5037
5295
|
initialized = true;
|
|
@@ -5049,14 +5307,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5049
5307
|
startCloudSubscription(current, workspace);
|
|
5050
5308
|
return {success: true, tokenRefreshed: true};
|
|
5051
5309
|
}
|
|
5052
|
-
|
|
5053
|
-
workspace = rtlEmptyDurableWorkspace({
|
|
5054
|
-
ownerUid: current.uid,
|
|
5055
|
-
workspaceEpoch: current.workspaceEpoch
|
|
5056
|
-
});
|
|
5057
|
-
initialized = false;
|
|
5058
|
-
hydrationRequired = true;
|
|
5059
|
-
notify({type: 'session_changed'});
|
|
5310
|
+
resetForSessionIdentity(current);
|
|
5060
5311
|
return {success: true, sessionChanged: true};
|
|
5061
5312
|
}).catch((error) => {
|
|
5062
5313
|
logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
|
|
@@ -5388,14 +5639,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5388
5639
|
nextPending.push(makeRetryOperation(operation, result, null, timestamp));
|
|
5389
5640
|
} else if (result.status === 'rejected') {
|
|
5390
5641
|
completedOperations.push({operation, status: result.status});
|
|
5391
|
-
nextRejected
|
|
5642
|
+
Object.defineProperty(nextRejected, operation.id, {
|
|
5643
|
+
value: {
|
|
5392
5644
|
id: operation.id,
|
|
5393
5645
|
operation: clone(operation),
|
|
5394
5646
|
status: 'rejected',
|
|
5395
5647
|
reason: result.reason ?? result.error ?? result.code ?? 'rejected',
|
|
5396
5648
|
rejectedAt: timestamp,
|
|
5397
5649
|
response: rtlStripSessionTokens(result)
|
|
5398
|
-
|
|
5650
|
+
},
|
|
5651
|
+
enumerable: true,
|
|
5652
|
+
configurable: true,
|
|
5653
|
+
writable: true
|
|
5654
|
+
});
|
|
5399
5655
|
}
|
|
5400
5656
|
}
|
|
5401
5657
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
@@ -5930,8 +6186,8 @@ const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payl
|
|
|
5930
6186
|
'recordId', 'id'
|
|
5931
6187
|
]) ?? operation?.payload?.record?.id ?? null;
|
|
5932
6188
|
const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5933
|
-
'folderId', 'id'
|
|
5934
|
-
]) ?? operation?.payload?.folder?.id ?? null;
|
|
6189
|
+
'targetFolderId', 'folderId', 'id'
|
|
6190
|
+
]) ?? operation?.payload?.folder?.id ?? operation?.payload?.record?.folderId ?? null;
|
|
5935
6191
|
const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5936
6192
|
'trashEntryId', 'id'
|
|
5937
6193
|
]) ?? null;
|
|
@@ -5990,6 +6246,19 @@ const rtlPayloadOrder = (operation, keys) => {
|
|
|
5990
6246
|
}
|
|
5991
6247
|
return [];
|
|
5992
6248
|
};
|
|
6249
|
+
// Partial delete reads omit sibling records, so folder.recordOrder is the live slot.
|
|
6250
|
+
const rtlResolveOriginalRecordIndex = (payload, existingRecord, folders) => {
|
|
6251
|
+
if (Number.isInteger(payload?.originalRecordIndex) && payload.originalRecordIndex >= 0) {
|
|
6252
|
+
return payload.originalRecordIndex;
|
|
6253
|
+
}
|
|
6254
|
+
const recordId = normalizeId(existingRecord?.id);
|
|
6255
|
+
const folderId = safeFolderId(existingRecord?.folderId || payload?.folderId);
|
|
6256
|
+
const recordOrder = rtlMergeOrder(
|
|
6257
|
+
rtlOwn(folders, folderId) ? folders[folderId]?.recordOrder : []
|
|
6258
|
+
);
|
|
6259
|
+
const orderIndex = recordId ? recordOrder.indexOf(recordId) : -1;
|
|
6260
|
+
return orderIndex >= 0 ? orderIndex : 0;
|
|
6261
|
+
};
|
|
5993
6262
|
const rtlHasInvalidPayloadValue = (value, depth = 0) => {
|
|
5994
6263
|
if (depth > 20) return true;
|
|
5995
6264
|
if (typeof value === 'number') return !Number.isFinite(value);
|
|
@@ -6002,12 +6271,13 @@ const rtlHasInvalidPayloadValue = (value, depth = 0) => {
|
|
|
6002
6271
|
};
|
|
6003
6272
|
|
|
6004
6273
|
const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
6005
|
-
const rtlValidateIdList = (value, index, field, errors) => {
|
|
6274
|
+
const rtlValidateIdList = (value, index, field, errors, {opaqueGroup = false} = {}) => {
|
|
6006
6275
|
if (!Array.isArray(value)) {
|
|
6007
6276
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
6008
6277
|
return;
|
|
6009
6278
|
}
|
|
6010
|
-
|
|
6279
|
+
const isValid = opaqueGroup ? rtlIsSafeOpaqueGroupId : rtlIsSafeDocumentId;
|
|
6280
|
+
if (value.some((id) => !isValid(id))) {
|
|
6011
6281
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
6012
6282
|
}
|
|
6013
6283
|
};
|
|
@@ -6027,8 +6297,13 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
6027
6297
|
break;
|
|
6028
6298
|
case OPERATION_TYPES.RECORD_MOVE:
|
|
6029
6299
|
if (!recordId) errors.push(`operation_${index}_missing_record_id`);
|
|
6030
|
-
|
|
6031
|
-
|
|
6300
|
+
{
|
|
6301
|
+
const targetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6302
|
+
if (targetFolderId === undefined || targetFolderId === null) {
|
|
6303
|
+
errors.push(`operation_${index}_missing_target_folder_id`);
|
|
6304
|
+
} else if (!rtlIsSafeDocumentId(targetFolderId)) {
|
|
6305
|
+
errors.push(`operation_${index}_invalid_target_folder_id`);
|
|
6306
|
+
}
|
|
6032
6307
|
}
|
|
6033
6308
|
break;
|
|
6034
6309
|
case OPERATION_TYPES.RECORD_DELETE:
|
|
@@ -6076,14 +6351,21 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
6076
6351
|
rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
|
|
6077
6352
|
break;
|
|
6078
6353
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
6079
|
-
rtlValidateIdList(
|
|
6354
|
+
rtlValidateIdList(
|
|
6355
|
+
payload.groupOrder || payload.order,
|
|
6356
|
+
index,
|
|
6357
|
+
'group_order',
|
|
6358
|
+
errors,
|
|
6359
|
+
{opaqueGroup: true}
|
|
6360
|
+
);
|
|
6080
6361
|
break;
|
|
6081
6362
|
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
6082
6363
|
rtlValidateIdList(
|
|
6083
6364
|
payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
|
|
6084
6365
|
index,
|
|
6085
6366
|
'expanded_groups',
|
|
6086
|
-
errors
|
|
6367
|
+
errors,
|
|
6368
|
+
{opaqueGroup: true}
|
|
6087
6369
|
);
|
|
6088
6370
|
break;
|
|
6089
6371
|
case OPERATION_TYPES.SETTINGS_UPDATE:
|
|
@@ -6503,8 +6785,17 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6503
6785
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
6504
6786
|
break;
|
|
6505
6787
|
}
|
|
6788
|
+
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6789
|
+
if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
|
|
6790
|
+
reason = 'missing_target_folder_id';
|
|
6791
|
+
break;
|
|
6792
|
+
}
|
|
6793
|
+
if (!rtlIsSafeDocumentId(rawTargetFolderId)) {
|
|
6794
|
+
reason = 'invalid_document_id';
|
|
6795
|
+
break;
|
|
6796
|
+
}
|
|
6506
6797
|
const sourceFolderId = safeFolderId(existing.folderId);
|
|
6507
|
-
const targetFolderId =
|
|
6798
|
+
const targetFolderId = rawTargetFolderId;
|
|
6508
6799
|
applied = rtlApplyOperationToPartialDocuments({
|
|
6509
6800
|
...nextDocuments,
|
|
6510
6801
|
operation: {...operation, payload: {...payload, targetFolderId}},
|
|
@@ -6599,7 +6890,21 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6599
6890
|
break;
|
|
6600
6891
|
}
|
|
6601
6892
|
const sourceFolderId = safeFolderId(existingRecord.folderId);
|
|
6602
|
-
applied = rtlApplyOperationToPartialDocuments({
|
|
6893
|
+
applied = rtlApplyOperationToPartialDocuments({
|
|
6894
|
+
...nextDocuments,
|
|
6895
|
+
operation: {
|
|
6896
|
+
...operation,
|
|
6897
|
+
payload: {
|
|
6898
|
+
...payload,
|
|
6899
|
+
originalRecordIndex: rtlResolveOriginalRecordIndex(
|
|
6900
|
+
payload,
|
|
6901
|
+
existingRecord,
|
|
6902
|
+
nextDocuments.folders
|
|
6903
|
+
)
|
|
6904
|
+
}
|
|
6905
|
+
},
|
|
6906
|
+
now: operationNow
|
|
6907
|
+
});
|
|
6603
6908
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
6604
6909
|
const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
|
|
6605
6910
|
? applied.documents.trash[operationTrashEntryId]
|
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
|