@recordtimelabel/core 0.6.1 → 0.6.2
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 +20 -2
- package/package.json +1 -1
- package/src/index.js +129 -31
- package/src/protocol.js +236 -1
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 current published artifact 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 current published artifact is `0.6.2`:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.6.
|
|
23
|
+
"@recordtimelabel/core": "0.6.2"
|
|
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.
|
|
@@ -36,6 +36,10 @@ If this checkout's `package.json` is ahead of the published version, publish the
|
|
|
36
36
|
- `RTL_MAX_SYNC_DRAIN_ROUNDS`
|
|
37
37
|
- `RTL_SYNC_DRAIN_RETRY_DELAY_MS`
|
|
38
38
|
- `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
|
|
39
|
+
- `RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE`
|
|
40
|
+
- `RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES`
|
|
41
|
+
- `normalizeRecordTimeLabelCloudFailure(input)`
|
|
42
|
+
- `toRecordTimeLabelCloudFailureError(input)`
|
|
39
43
|
- `RECORD_TIMELABEL_PROTOCOL_CAPABILITIES`
|
|
40
44
|
- `toRecordTimeLabelWireOperation(operation)`
|
|
41
45
|
- `buildRecordTimeLabelRequestId(namespace, operations)`
|
|
@@ -97,6 +101,20 @@ has `load()` and `save(workspace)` methods (adapters may consume the optional se
|
|
|
97
101
|
`save(workspace, fenceContext)` argument to enforce the session/epoch atomically), and its cloud port has `bootstrap(context, attemptOptions)`,
|
|
98
102
|
`applyOperations(envelope, context)`, and `subscribe(listener, context)` methods. The session port
|
|
99
103
|
provides `current()`, `subscribe(listener)`, and `isCurrent(sessionToken, uid, workspaceEpoch)`.
|
|
104
|
+
`sessionToken` is an opaque fence for equality only. Core never aliases `current.id` or
|
|
105
|
+
the whole session object as an authorization token, and `bootstrap`/`catchUp`/`apply`
|
|
106
|
+
context no longer carries `token`. Platform adapters obtain Firebase credentials from
|
|
107
|
+
their own credential provider.
|
|
108
|
+
|
|
109
|
+
`normalizeRecordTimeLabelCloudFailure` preserves `status`, `reason`, `retryable`, and
|
|
110
|
+
`retryAfterMs`, and assigns exactly one class: `transient`, `bootstrap-required`,
|
|
111
|
+
`auth-transition-required`, `terminal`, or `stale-session`. Capability
|
|
112
|
+
`cloud-failure-state-v1` advertises this contract. An `auth-transition-required`
|
|
113
|
+
bootstrap or catch-up failure latches that session identity so repeated `init()`
|
|
114
|
+
calls do not invoke `cloud.bootstrap` again. A changed SessionPort capture, UID, or
|
|
115
|
+
workspace epoch clears the latch. `recoverBaseline` falls back from catch-up only for
|
|
116
|
+
explicit `bootstrap-required` / cursor-gap outcomes; auth and transient failures keep
|
|
117
|
+
their class and must not start a second full walk.
|
|
100
118
|
|
|
101
119
|
The persisted workspace is versioned and contains only durable data:
|
|
102
120
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
2
3
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
3
4
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
4
5
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
6
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
5
7
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
6
8
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
7
9
|
buildRecordTimeLabelRequestId,
|
|
8
10
|
createRecordTimeLabelTransportFailureResults,
|
|
11
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
9
12
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
10
13
|
normalizeRecordTimeLabelOperationResults,
|
|
14
|
+
toRecordTimeLabelCloudFailureError,
|
|
11
15
|
toRecordTimeLabelWireOperation
|
|
12
16
|
} from './protocol.js';
|
|
13
17
|
|
|
14
18
|
export {
|
|
19
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
15
20
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
16
21
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
17
22
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
23
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
18
24
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
19
25
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
20
26
|
buildRecordTimeLabelRequestId,
|
|
21
27
|
createRecordTimeLabelTransportFailureResults,
|
|
28
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
22
29
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
23
30
|
normalizeRecordTimeLabelOperationResults,
|
|
31
|
+
toRecordTimeLabelCloudFailureError,
|
|
24
32
|
toRecordTimeLabelWireOperation
|
|
25
33
|
} from './protocol.js';
|
|
26
34
|
|
|
@@ -119,7 +127,7 @@ export {
|
|
|
119
127
|
selectBestMatchingTwitchVod
|
|
120
128
|
};
|
|
121
129
|
|
|
122
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
130
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.2';
|
|
123
131
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
124
132
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
125
133
|
'fifo-retry-fence',
|
|
@@ -128,7 +136,8 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
128
136
|
'remote-subscription-readiness',
|
|
129
137
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
130
138
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
131
|
-
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
139
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
140
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE
|
|
132
141
|
]);
|
|
133
142
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
134
143
|
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
@@ -3354,12 +3363,12 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3354
3363
|
} catch {
|
|
3355
3364
|
current = null;
|
|
3356
3365
|
}
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
?
|
|
3362
|
-
:
|
|
3366
|
+
// SessionPort.sessionToken is an opaque fence for equality only. Never treat
|
|
3367
|
+
// current.id or the whole session object as an authorization credential.
|
|
3368
|
+
const sessionToken = current && typeof current === 'object' &&
|
|
3369
|
+
Object.prototype.hasOwnProperty.call(current, 'sessionToken')
|
|
3370
|
+
? current.sessionToken
|
|
3371
|
+
: null;
|
|
3363
3372
|
const uid = rtlNormalizeUid(current && typeof current === 'object'
|
|
3364
3373
|
? (current.uid ?? current.ownerUid)
|
|
3365
3374
|
: null);
|
|
@@ -3370,8 +3379,7 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3370
3379
|
return {
|
|
3371
3380
|
current,
|
|
3372
3381
|
hasSession: typeof session?.current === 'function',
|
|
3373
|
-
|
|
3374
|
-
sessionToken: token,
|
|
3382
|
+
sessionToken,
|
|
3375
3383
|
uid,
|
|
3376
3384
|
workspaceEpoch: hasEpoch
|
|
3377
3385
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
@@ -3380,9 +3388,16 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3380
3388
|
};
|
|
3381
3389
|
};
|
|
3382
3390
|
|
|
3391
|
+
const rtlSameSessionIdentity = (left, right) => (
|
|
3392
|
+
Boolean(left) &&
|
|
3393
|
+
Boolean(right) &&
|
|
3394
|
+
left.uid === right.uid &&
|
|
3395
|
+
Number(left.workspaceEpoch) === Number(right.workspaceEpoch) &&
|
|
3396
|
+
Object.is(left.sessionToken, right.sessionToken)
|
|
3397
|
+
);
|
|
3398
|
+
|
|
3383
3399
|
const rtlSessionContext = (captured, workspace, client) => ({
|
|
3384
3400
|
sessionToken: captured?.sessionToken ?? null,
|
|
3385
|
-
token: captured?.token ?? null,
|
|
3386
3401
|
uid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
3387
3402
|
ownerUid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
3388
3403
|
workspaceEpoch: workspace?.workspaceEpoch ?? captured?.workspaceEpoch ?? 0,
|
|
@@ -3516,6 +3531,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3516
3531
|
})
|
|
3517
3532
|
};
|
|
3518
3533
|
let bootstrapAttemptSequence = 0;
|
|
3534
|
+
let authTransitionLatch = null;
|
|
3519
3535
|
const listeners = new Set();
|
|
3520
3536
|
let queue = Promise.resolve();
|
|
3521
3537
|
|
|
@@ -3568,7 +3584,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3568
3584
|
}
|
|
3569
3585
|
if (typeof session?.current !== 'function') return true;
|
|
3570
3586
|
const current = rtlExtractSession(session, captured.workspaceEpoch);
|
|
3571
|
-
if (
|
|
3587
|
+
if (
|
|
3588
|
+
captured.sessionToken !== undefined &&
|
|
3589
|
+
captured.sessionToken !== null &&
|
|
3590
|
+
!Object.is(current.sessionToken, captured.sessionToken)
|
|
3591
|
+
) return false;
|
|
3572
3592
|
if (captured.uid !== current.uid) return false;
|
|
3573
3593
|
return !captured.hasEpoch || current.workspaceEpoch === captured.workspaceEpoch;
|
|
3574
3594
|
};
|
|
@@ -3618,21 +3638,75 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3618
3638
|
|
|
3619
3639
|
const normalizeBootstrapResponse = (response) => {
|
|
3620
3640
|
if (!rtlEnvelopeSuccess(response)) {
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3641
|
+
throw toRecordTimeLabelCloudFailureError({
|
|
3642
|
+
...((response && typeof response === 'object') ? response : {}),
|
|
3643
|
+
message: response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed',
|
|
3644
|
+
code: response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed',
|
|
3645
|
+
reason: response?.error?.reason || response?.reason ||
|
|
3646
|
+
response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed'
|
|
3647
|
+
});
|
|
3624
3648
|
}
|
|
3625
3649
|
const remote = rtlEnvelopePayload(response);
|
|
3626
3650
|
const revision = Number(remote?.revision);
|
|
3627
3651
|
const state = remote?.state ?? remote?.data;
|
|
3628
3652
|
if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3653
|
+
throw toRecordTimeLabelCloudFailureError({
|
|
3654
|
+
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
3655
|
+
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
3656
|
+
message: 'recordtimelabel_invalid_bootstrap_response',
|
|
3657
|
+
retryable: false
|
|
3658
|
+
});
|
|
3632
3659
|
}
|
|
3633
3660
|
return {state, revision, changeCursor: remote.changeCursor ?? null};
|
|
3634
3661
|
};
|
|
3635
3662
|
|
|
3663
|
+
const clearAuthTransitionLatchIfSessionChanged = (captured) => {
|
|
3664
|
+
if (!authTransitionLatch) return;
|
|
3665
|
+
if (!rtlSameSessionIdentity(authTransitionLatch, captured)) {
|
|
3666
|
+
authTransitionLatch = null;
|
|
3667
|
+
}
|
|
3668
|
+
};
|
|
3669
|
+
|
|
3670
|
+
const throwIfAuthTransitionLatched = (captured) => {
|
|
3671
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
3672
|
+
if (!authTransitionLatch) return;
|
|
3673
|
+
throw toRecordTimeLabelCloudFailureError(authTransitionLatch.failure);
|
|
3674
|
+
};
|
|
3675
|
+
|
|
3676
|
+
const rememberAuthTransitionFailure = (captured, failure) => {
|
|
3677
|
+
if (failure?.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
authTransitionLatch = {
|
|
3681
|
+
uid: captured?.uid ?? null,
|
|
3682
|
+
sessionToken: captured?.sessionToken ?? null,
|
|
3683
|
+
workspaceEpoch: captured?.workspaceEpoch ?? 0,
|
|
3684
|
+
failure: {
|
|
3685
|
+
class: failure.class,
|
|
3686
|
+
status: failure.status ?? null,
|
|
3687
|
+
reason: failure.reason ?? failure.code ?? 'auth-transition-required',
|
|
3688
|
+
code: failure.code ?? failure.reason ?? 'auth-transition-required',
|
|
3689
|
+
message: failure.message || failure.reason || failure.code || 'auth-transition-required',
|
|
3690
|
+
retryable: false,
|
|
3691
|
+
retryAfterMs: null,
|
|
3692
|
+
bootstrapRequired: false
|
|
3693
|
+
}
|
|
3694
|
+
};
|
|
3695
|
+
};
|
|
3696
|
+
|
|
3697
|
+
const capturedFromContext = (context) => ({
|
|
3698
|
+
uid: context?.uid ?? null,
|
|
3699
|
+
sessionToken: context?.sessionToken ?? null,
|
|
3700
|
+
workspaceEpoch: context?.workspaceEpoch ?? 0
|
|
3701
|
+
});
|
|
3702
|
+
|
|
3703
|
+
const rethrowClassifiedCloudFailure = (captured, error) => {
|
|
3704
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
3705
|
+
rememberAuthTransitionFailure(captured, failure);
|
|
3706
|
+
if (error?.class === failure.class) throw error;
|
|
3707
|
+
throw toRecordTimeLabelCloudFailureError(failure);
|
|
3708
|
+
};
|
|
3709
|
+
|
|
3636
3710
|
const requireBootstrapRevision = (baseline, minimumRevision) => {
|
|
3637
3711
|
const minimum = Number(minimumRevision);
|
|
3638
3712
|
if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
|
|
@@ -3651,10 +3725,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3651
3725
|
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3652
3726
|
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3653
3727
|
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3654
|
-
// bootstrap walk
|
|
3655
|
-
//
|
|
3656
|
-
//
|
|
3728
|
+
// bootstrap walk only for explicit bootstrap-required / cursor-gap
|
|
3729
|
+
// outcomes. Auth and transient failures keep their class and must not
|
|
3730
|
+
// start a second full walk. A successful but stale catch-up response is
|
|
3731
|
+
// fail-closed: falling back would hide a revision contract violation.
|
|
3657
3732
|
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
3733
|
+
const captured = capturedFromContext(context);
|
|
3734
|
+
throwIfAuthTransitionLatched(captured);
|
|
3658
3735
|
if (typeof cloud?.catchUp === 'function') {
|
|
3659
3736
|
try {
|
|
3660
3737
|
const caught = await cloud.catchUp(context, {
|
|
@@ -3665,20 +3742,35 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3665
3742
|
if (rtlEnvelopeSuccess(caught)) {
|
|
3666
3743
|
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3667
3744
|
}
|
|
3745
|
+
const failure = normalizeRecordTimeLabelCloudFailure(caught);
|
|
3746
|
+
if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
3747
|
+
rethrowClassifiedCloudFailure(captured, failure);
|
|
3748
|
+
}
|
|
3668
3749
|
} catch (error) {
|
|
3669
3750
|
if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
|
|
3670
3751
|
throw error;
|
|
3671
3752
|
}
|
|
3672
|
-
|
|
3753
|
+
if (error?.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED ||
|
|
3754
|
+
normalizeRecordTimeLabelCloudFailure(error).class ===
|
|
3755
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
3756
|
+
// Explicit gap recovery only.
|
|
3757
|
+
} else {
|
|
3758
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
3759
|
+
}
|
|
3673
3760
|
}
|
|
3674
3761
|
}
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3762
|
+
throwIfAuthTransitionLatched(captured);
|
|
3763
|
+
try {
|
|
3764
|
+
return requireBootstrapRevision(
|
|
3765
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3766
|
+
context,
|
|
3767
|
+
bootstrapAttemptOptions(mode)
|
|
3768
|
+
)),
|
|
3769
|
+
targetRevision
|
|
3770
|
+
);
|
|
3771
|
+
} catch (error) {
|
|
3772
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
3773
|
+
}
|
|
3682
3774
|
};
|
|
3683
3775
|
|
|
3684
3776
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
@@ -4012,6 +4104,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4012
4104
|
|
|
4013
4105
|
const initialize = async () => {
|
|
4014
4106
|
const captured = capture();
|
|
4107
|
+
throwIfAuthTransitionLatched(captured);
|
|
4015
4108
|
const loaded = await storage.load();
|
|
4016
4109
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4017
4110
|
// Read the raw owner and baseline before normalizeLoadedWorkspace adopts
|
|
@@ -4050,7 +4143,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4050
4143
|
));
|
|
4051
4144
|
} catch (error) {
|
|
4052
4145
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4053
|
-
|
|
4146
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4054
4147
|
}
|
|
4055
4148
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4056
4149
|
applyRemoteBaseline(candidate, bootstrap);
|
|
@@ -4068,6 +4161,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4068
4161
|
if (destroyed) return;
|
|
4069
4162
|
enqueue(async () => {
|
|
4070
4163
|
const current = capture();
|
|
4164
|
+
clearAuthTransitionLatchIfSessionChanged(current);
|
|
4071
4165
|
const sameIdentity = current.uid === workspace.ownerUid &&
|
|
4072
4166
|
Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
|
|
4073
4167
|
if (sameIdentity) {
|
|
@@ -4420,7 +4514,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4420
4514
|
);
|
|
4421
4515
|
} catch (error) {
|
|
4422
4516
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4423
|
-
|
|
4517
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4424
4518
|
}
|
|
4425
4519
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4426
4520
|
applyRemoteBaseline(candidate, freshBaseline);
|
|
@@ -5664,9 +5758,13 @@ export default {
|
|
|
5664
5758
|
isPendingChannelFolderId,
|
|
5665
5759
|
isValidChannelName,
|
|
5666
5760
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5761
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
5667
5762
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5668
5763
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5669
5764
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
5765
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
5766
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
5767
|
+
toRecordTimeLabelCloudFailureError,
|
|
5670
5768
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
5671
5769
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
5672
5770
|
RTL_SYNC_PROTOCOL_VERSION,
|
package/src/protocol.js
CHANGED
|
@@ -23,6 +23,237 @@ export const RECORD_TIMELABEL_PROTOCOL_CAPABILITIES = Object.freeze([
|
|
|
23
23
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
24
24
|
]);
|
|
25
25
|
|
|
26
|
+
export const RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES = Object.freeze({
|
|
27
|
+
TRANSIENT: 'transient',
|
|
28
|
+
BOOTSTRAP_REQUIRED: 'bootstrap-required',
|
|
29
|
+
AUTH_TRANSITION_REQUIRED: 'auth-transition-required',
|
|
30
|
+
TERMINAL: 'terminal',
|
|
31
|
+
STALE_SESSION: 'stale-session'
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE = 'cloud-failure-state-v1';
|
|
35
|
+
|
|
36
|
+
const CLOUD_FAILURE_CLASS_VALUES = new Set(Object.values(RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES));
|
|
37
|
+
|
|
38
|
+
const AUTH_TRANSITION_CODES = new Set([
|
|
39
|
+
'auth-transition-required',
|
|
40
|
+
'auth_transition_required',
|
|
41
|
+
'malformed_id_token',
|
|
42
|
+
'invalid_id_token',
|
|
43
|
+
'unauthenticated',
|
|
44
|
+
'expired_id_token',
|
|
45
|
+
'id_token_expired',
|
|
46
|
+
'token_expired',
|
|
47
|
+
'token_project_mismatch',
|
|
48
|
+
'token_user_mismatch',
|
|
49
|
+
'token_not_yet_valid',
|
|
50
|
+
'missing_token',
|
|
51
|
+
'missing_uid',
|
|
52
|
+
'token_refresh_failed',
|
|
53
|
+
'token_refresh_required'
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const BOOTSTRAP_REQUIRED_CODES = new Set([
|
|
57
|
+
'bootstrap-required',
|
|
58
|
+
'bootstrap_required',
|
|
59
|
+
'bootstraprequired',
|
|
60
|
+
'expired_bootstrap_cursor',
|
|
61
|
+
'invalid_bootstrap_cursor',
|
|
62
|
+
'revision_gap',
|
|
63
|
+
'recordtimelabel_bootstrap_required'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const STALE_SESSION_CODES = new Set([
|
|
67
|
+
'stale-session',
|
|
68
|
+
'stale_session',
|
|
69
|
+
'recordtimelabel_stale_session',
|
|
70
|
+
'auth_context_changed'
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const TERMINAL_CONFLICT_CODES = new Set([
|
|
74
|
+
'operation_id_conflict',
|
|
75
|
+
'operation_request_invalid_ids',
|
|
76
|
+
'operation_result_count_mismatch',
|
|
77
|
+
'operation_result_incomplete',
|
|
78
|
+
'operation_result_duplicate_id',
|
|
79
|
+
'operation_result_unknown_id',
|
|
80
|
+
'operation_result_missing_id',
|
|
81
|
+
'operation_result_ambiguous_id'
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const pickFailureText = (...values) => {
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const unwrapFailureSource = (input) => {
|
|
92
|
+
if (input == null) return {};
|
|
93
|
+
if (typeof input === 'string') return {message: input, reason: input, code: input};
|
|
94
|
+
if (typeof input !== 'object') return {message: String(input)};
|
|
95
|
+
const nested = input.error && typeof input.error === 'object' ? input.error : null;
|
|
96
|
+
return nested ? {...nested, ...input, error: nested} : input;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const normalizeFailureClass = (value) => (
|
|
100
|
+
CLOUD_FAILURE_CLASS_VALUES.has(value) ? value : null
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const normalizeFailureRetryAfterMs = (value) => {
|
|
104
|
+
if (value === null || value === undefined || value === '') return null;
|
|
105
|
+
const retryAfterMs = Number(value);
|
|
106
|
+
return Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const collectFailureTokens = (source) => [
|
|
110
|
+
source.class,
|
|
111
|
+
source.failureClass,
|
|
112
|
+
source.cloudFailureClass,
|
|
113
|
+
source.code,
|
|
114
|
+
source.reason,
|
|
115
|
+
source.name,
|
|
116
|
+
source.message,
|
|
117
|
+
source.error,
|
|
118
|
+
source.error?.code,
|
|
119
|
+
source.error?.reason,
|
|
120
|
+
source.error?.message,
|
|
121
|
+
source.error?.name
|
|
122
|
+
].flatMap((value) => {
|
|
123
|
+
if (typeof value !== 'string') return [];
|
|
124
|
+
return value.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const looksLikeNetworkFailure = (source, tokens) => {
|
|
128
|
+
const blob = [
|
|
129
|
+
source.name,
|
|
130
|
+
source.code,
|
|
131
|
+
source.reason,
|
|
132
|
+
source.message,
|
|
133
|
+
source.error?.name,
|
|
134
|
+
source.error?.code,
|
|
135
|
+
source.error?.reason,
|
|
136
|
+
source.error?.message
|
|
137
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
138
|
+
return Boolean(
|
|
139
|
+
tokens.includes('retryable') ||
|
|
140
|
+
tokens.includes('unavailable') ||
|
|
141
|
+
tokens.includes('temporarily_unavailable') ||
|
|
142
|
+
tokens.includes('bulk_job_in_progress') ||
|
|
143
|
+
tokens.includes('deadline_exceeded') ||
|
|
144
|
+
tokens.includes('network_error') ||
|
|
145
|
+
tokens.includes('err_network') ||
|
|
146
|
+
tokens.includes('econnreset') ||
|
|
147
|
+
tokens.includes('etimedout') ||
|
|
148
|
+
blob.includes('failed to fetch') ||
|
|
149
|
+
blob.includes('fetch failed') ||
|
|
150
|
+
blob.includes('network-request-failed') ||
|
|
151
|
+
blob.includes('network request failed')
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
|
|
156
|
+
const explicitClass = normalizeFailureClass(
|
|
157
|
+
source.class ?? source.failureClass ?? source.cloudFailureClass
|
|
158
|
+
);
|
|
159
|
+
if (explicitClass) return explicitClass;
|
|
160
|
+
if (
|
|
161
|
+
source.stale === true ||
|
|
162
|
+
tokens.some((token) => STALE_SESSION_CODES.has(token) || token === 'stale-session')
|
|
163
|
+
) {
|
|
164
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
status === 401 ||
|
|
168
|
+
tokens.some((token) => AUTH_TRANSITION_CODES.has(token))
|
|
169
|
+
) {
|
|
170
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
source.bootstrapRequired === true ||
|
|
174
|
+
source.bootstrap_required === true ||
|
|
175
|
+
tokens.some((token) => BOOTSTRAP_REQUIRED_CODES.has(token))
|
|
176
|
+
) {
|
|
177
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
|
|
178
|
+
}
|
|
179
|
+
if (tokens.some((token) => TERMINAL_CONFLICT_CODES.has(token))) {
|
|
180
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
|
|
181
|
+
}
|
|
182
|
+
const retryable = source.retryable === true || source.error?.retryable === true;
|
|
183
|
+
if (
|
|
184
|
+
status === 408 ||
|
|
185
|
+
status === 425 ||
|
|
186
|
+
status === 429 ||
|
|
187
|
+
status === 409 ||
|
|
188
|
+
(Number.isFinite(status) && status >= 500) ||
|
|
189
|
+
retryable ||
|
|
190
|
+
looksLikeNetworkFailure(source, tokens)
|
|
191
|
+
) {
|
|
192
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT;
|
|
193
|
+
}
|
|
194
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classify a cloud/bootstrap/catch-up failure without inspecting JWT structure
|
|
199
|
+
* or Firebase SDKs. Adapters may pass Firebase codes as opaque reason/code
|
|
200
|
+
* strings; this helper only preserves metadata and assigns one class.
|
|
201
|
+
*/
|
|
202
|
+
export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
|
|
203
|
+
const source = unwrapFailureSource(input);
|
|
204
|
+
const statusValue = Number(source.status ?? source.statusCode ?? source.error?.status);
|
|
205
|
+
const status = Number.isFinite(statusValue) ? statusValue : null;
|
|
206
|
+
const tokens = collectFailureTokens(source);
|
|
207
|
+
const failureClass = classifyRecordTimeLabelCloudFailure(source, status, tokens);
|
|
208
|
+
const reason = pickFailureText(
|
|
209
|
+
source.reason,
|
|
210
|
+
source.error?.reason,
|
|
211
|
+
source.code,
|
|
212
|
+
source.error?.code,
|
|
213
|
+
source.message,
|
|
214
|
+
source.error?.message
|
|
215
|
+
);
|
|
216
|
+
const code = pickFailureText(
|
|
217
|
+
source.code,
|
|
218
|
+
source.error?.code,
|
|
219
|
+
reason,
|
|
220
|
+
'recordtimelabel_cloud_failure'
|
|
221
|
+
);
|
|
222
|
+
const message = pickFailureText(
|
|
223
|
+
source.message,
|
|
224
|
+
source.error?.message,
|
|
225
|
+
reason,
|
|
226
|
+
code,
|
|
227
|
+
'recordtimelabel_cloud_failure'
|
|
228
|
+
);
|
|
229
|
+
const retryAfterMs = failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT
|
|
230
|
+
? normalizeFailureRetryAfterMs(source.retryAfterMs ?? source.error?.retryAfterMs)
|
|
231
|
+
: null;
|
|
232
|
+
return {
|
|
233
|
+
class: failureClass,
|
|
234
|
+
status,
|
|
235
|
+
reason,
|
|
236
|
+
code,
|
|
237
|
+
message,
|
|
238
|
+
retryable: failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT,
|
|
239
|
+
retryAfterMs,
|
|
240
|
+
bootstrapRequired: failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const toRecordTimeLabelCloudFailureError = (input = {}) => {
|
|
245
|
+
const failure = normalizeRecordTimeLabelCloudFailure(input);
|
|
246
|
+
const error = new Error(failure.message);
|
|
247
|
+
error.class = failure.class;
|
|
248
|
+
error.code = failure.code;
|
|
249
|
+
error.reason = failure.reason;
|
|
250
|
+
error.status = failure.status;
|
|
251
|
+
error.retryable = failure.retryable;
|
|
252
|
+
error.retryAfterMs = failure.retryAfterMs;
|
|
253
|
+
error.bootstrapRequired = failure.bootstrapRequired;
|
|
254
|
+
return error;
|
|
255
|
+
};
|
|
256
|
+
|
|
26
257
|
const ALLOWED_OPERATION_RESULT_STATUSES = new Set(
|
|
27
258
|
Object.values(RECORD_TIMELABEL_OPERATION_RESULT_STATUSES)
|
|
28
259
|
);
|
|
@@ -269,10 +500,14 @@ export default {
|
|
|
269
500
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
270
501
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
271
502
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
503
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
504
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
272
505
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
273
506
|
toRecordTimeLabelWireOperation,
|
|
274
507
|
buildRecordTimeLabelRequestId,
|
|
275
508
|
createRecordTimeLabelTransportFailureResults,
|
|
276
509
|
normalizeRecordTimeLabelOperationResults,
|
|
277
|
-
normalizeRecordTimeLabelEnvelopeResponse
|
|
510
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
511
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
512
|
+
toRecordTimeLabelCloudFailureError
|
|
278
513
|
};
|