@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 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.1`:
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.1"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
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.1';
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
- // A session port is intentionally small and adapters use both token and
3358
- // sessionToken spellings. Preserve the original token (including objects)
3359
- // for isCurrent, but never put it in a workspace blob.
3360
- const token = current && typeof current === 'object'
3361
- ? (current.sessionToken ?? current.token ?? current.id ?? current)
3362
- : current;
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
- token,
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 (captured.token !== undefined && captured.token !== null && current.token !== captured.token) return false;
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
- const error = new Error(response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed');
3622
- error.code = response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed';
3623
- throw error;
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
- const error = new Error('recordtimelabel_invalid_bootstrap_response');
3630
- error.code = 'recordtimelabel_invalid_bootstrap_response';
3631
- throw error;
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 on transport/protocol failure. A successful but stale
3655
- // catch-up response is fail-closed: falling back would hide a revision
3656
- // contract violation and could apply a baseline older than the ACK.
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
- logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
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
- return requireBootstrapRevision(
3676
- normalizeBootstrapResponse(await cloud.bootstrap(
3677
- context,
3678
- bootstrapAttemptOptions(mode)
3679
- )),
3680
- targetRevision
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
- throw error;
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
- throw error;
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
  };