@recordtimelabel/core 0.6.1 → 0.6.4

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/src/protocol.js CHANGED
@@ -17,12 +17,560 @@ export const RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS =
17
17
  'strict-operation-results';
18
18
  export const RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE =
19
19
  'lifecycle-generation-fence';
20
+ export const RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE = 'cloud-failure-state-v1';
21
+ export const RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER = 'deterministic-planner-v1';
22
+ export const RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS = 'strict-readiness-v1';
23
+ // Keep the original capability spelling for rolling compatibility. New
24
+ // clients may advertise the versioned alias while old clients continue to
25
+ // advertise `lifecycle-generation-fence`.
26
+ export const RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1 =
27
+ 'lifecycle-generation-fence-v1';
28
+ export const RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION = 1;
20
29
  export const RECORD_TIMELABEL_PROTOCOL_CAPABILITIES = Object.freeze([
21
30
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
22
31
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
23
- RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
32
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
33
+ RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
34
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
35
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
36
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1
37
+ ]);
38
+
39
+ const IMMUTABLE_ID_MAX_BYTES = 512;
40
+ const IMMUTABLE_ID_MAX_CODEPOINTS = 512;
41
+ const immutableId = (value) => {
42
+ if (typeof value !== 'string' || value.length === 0 || value !== value.trim() ||
43
+ value !== value.normalize('NFC') ||
44
+ value === '.' || value === '..' || value === '__proto__' || value === 'prototype' || value === 'constructor' ||
45
+ value.includes('/') || value.includes('\\') ||
46
+ /[\u0000-\u001F\u007F-\u009F]/u.test(value) ||
47
+ /\p{Cf}/u.test(value) ||
48
+ Array.from(value).length > IMMUTABLE_ID_MAX_CODEPOINTS ||
49
+ (() => {
50
+ for (let index = 0; index < value.length; index += 1) {
51
+ const code = value.charCodeAt(index);
52
+ if (code >= 0xD800 && code <= 0xDBFF) {
53
+ const next = value.charCodeAt(index + 1);
54
+ if (!(next >= 0xDC00 && next <= 0xDFFF)) return true;
55
+ index += 1;
56
+ } else if (code >= 0xDC00 && code <= 0xDFFF) return true;
57
+ }
58
+ return false;
59
+ })() || new TextEncoder().encode(value).byteLength > IMMUTABLE_ID_MAX_BYTES) {
60
+ return null;
61
+ }
62
+ return value;
63
+ };
64
+
65
+ export const normalizeRecordTimeLabelImmutableId = immutableId;
66
+ export const normalizeRecordTimeLabelPlannerId = immutableId;
67
+
68
+ export const RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES = Object.freeze({
69
+ TRANSIENT: 'transient',
70
+ BOOTSTRAP_REQUIRED: 'bootstrap-required',
71
+ AUTH_TRANSITION_REQUIRED: 'auth-transition-required',
72
+ TERMINAL: 'terminal',
73
+ STALE_SESSION: 'stale-session'
74
+ });
75
+
76
+ const CLOUD_FAILURE_CLASS_VALUES = new Set(Object.values(RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES));
77
+
78
+ // Keep operation-conflict aliases in one wire-compatibility table. The
79
+ // durable engine consumes the exported predicate below instead of maintaining
80
+ // a second, subtly different list of aliases.
81
+ const OPERATION_CONFLICT_CODES = new Set([
82
+ 'operation_id_conflict',
83
+ 'operationidconflict',
84
+ 'operation_conflict',
85
+ 'operation_conflict_id',
86
+ 'operation_request_id_conflict',
87
+ 'operation_request_conflict',
88
+ 'operation_request_hash_conflict',
89
+ 'operation_id_hash_conflict',
90
+ 'operation_id_payload_conflict',
91
+ 'operation_id_content_mismatch',
92
+ 'operation_payload_conflict',
93
+ 'duplicate_operation_id',
94
+ 'duplicate_request_id',
95
+ 'request_id_conflict',
96
+ 'request_id_content_mismatch',
97
+ 'request_id_hash_conflict',
98
+ 'request_id_payload_mismatch',
99
+ 'request_hash_conflict',
100
+ 'request_hash_mismatch',
101
+ 'idempotency_conflict',
102
+ 'idempotency_key_conflict',
103
+ 'idempotency_key_mismatch',
104
+ 'recordtimelabel_operation_id_conflict'
24
105
  ]);
25
106
 
107
+ // These aliases are part of the wire compatibility surface. Keep the
108
+ // matching deliberately exact after canonicalisation: an arbitrary provider
109
+ // error message must not override an explicit HTTP/status heuristic, while a
110
+ // gateway's well-known code must.
111
+ const CLOUD_FAILURE_CODE_CLASSES = new Map([
112
+ ['transient', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
113
+ ['retryable', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
114
+ ['temporary', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
115
+ ['temporarily_unavailable', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
116
+ ['unavailable', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
117
+ ['deadline_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
118
+ ['bulk_job_in_progress', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
119
+ ['bootstrap_session_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
120
+ ['daily_bootstrap_read_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
121
+ ['rate_limited', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
122
+ ['too_many_requests', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
123
+ ['network_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
124
+ ['network_request_failed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
125
+ ['bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
126
+ ['bootstraprequired', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
127
+ ['recordtimelabel_bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
128
+ ['expired_bootstrap_cursor', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
129
+ ['invalid_bootstrap_cursor', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
130
+ ['revision_gap', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
131
+ ['cache_ahead', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
132
+ ['changed_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
133
+ ['root_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
134
+ ['stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
135
+ ['recordtimelabel_stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
136
+ ['auth_context_changed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
137
+ ['stale_uid', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
138
+ ['stale_epoch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
139
+ ['uid_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
140
+ ['workspace_epoch_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
141
+ ['auth_transition_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
142
+ ['auth_invalid', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
143
+ ['auth_revoked', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
144
+ ['auth_disabled', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
145
+ ['invalid_id_token', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
146
+ ['unauthenticated', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
147
+ ['permission_denied', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
148
+ // The wire class is also accepted in `code` for adapters that flatten a
149
+ // canonical failure. Keep this explicit rather than falling back to the
150
+ // HTTP status heuristic (a flattened terminal 409 must not become retryable).
151
+ ['terminal', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
152
+ ['validation_failed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
153
+ ['invalid_operation', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
154
+ ['invalid_operation_batch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
155
+ ['malformed_operation', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
156
+ ['operation_rejected', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
157
+ ['protocol_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
158
+ ['operation_result_count_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
159
+ ['lifecycle_conflict', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
160
+ ['lifecycle_generation_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
161
+ ['record_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
162
+ ['folder_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
163
+ ...[...OPERATION_CONFLICT_CODES].map((code) => [
164
+ code,
165
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL
166
+ ])
167
+ ]);
168
+
169
+ const pickFailureText = (...values) => {
170
+ for (const value of values) {
171
+ if (typeof value === 'string' && value.trim()) return value.trim();
172
+ }
173
+ return null;
174
+ };
175
+
176
+ const canonicalizeFailureCode = (value) => {
177
+ if (typeof value !== 'string') return null;
178
+ const normalized = value.trim().toLowerCase()
179
+ .replace(/[/:.\-\s]+/g, '_');
180
+ return normalized || null;
181
+ };
182
+
183
+ const failureCodeClass = (value) => {
184
+ const normalized = canonicalizeFailureCode(value);
185
+ return normalized ? CLOUD_FAILURE_CODE_CLASSES.get(normalized) || null : null;
186
+ };
187
+
188
+ const asFailureObject = (value) => (
189
+ value && typeof value === 'object' && !Array.isArray(value) ? value : null
190
+ );
191
+
192
+ const unwrapFailureSource = (input) => {
193
+ if (input == null) return {};
194
+ if (typeof input === 'string') return {message: input, reason: input, code: input};
195
+ if (typeof input !== 'object') return {message: String(input)};
196
+ const canonicalFailure = asFailureObject(input.failure);
197
+ const legacyError = asFailureObject(input.error);
198
+ const canonicalString = typeof input.failure === 'string' && input.failure.trim()
199
+ ? input.failure.trim()
200
+ : '';
201
+ const legacyString = typeof input.error === 'string' && input.error.trim()
202
+ ? input.error.trim()
203
+ : '';
204
+ if (!canonicalFailure && !legacyError && !canonicalString && !legacyString) return input;
205
+ const canonicalMarker = canonicalString || null;
206
+ const legacyMarker = legacyString || null;
207
+ const canonicalValue = canonicalFailure || (canonicalMarker ? {
208
+ message: canonicalMarker,
209
+ reason: canonicalMarker,
210
+ code: canonicalMarker
211
+ } : null);
212
+ const legacyValue = legacyError || (legacyMarker ? {
213
+ message: legacyMarker,
214
+ reason: legacyMarker,
215
+ code: legacyMarker
216
+ } : null);
217
+ // A canonical `failure` object is authoritative when an adapter also
218
+ // leaves legacy fields at the envelope level. Otherwise retain the old
219
+ // `error` object compatibility path.
220
+ return {
221
+ ...(legacyValue || {}),
222
+ ...input,
223
+ ...(legacyError ? {error: legacyError} : {}),
224
+ ...(canonicalValue || {}),
225
+ ...(canonicalFailure ? {failure: canonicalFailure} : {}),
226
+ reason: canonicalValue
227
+ ? pickFailureText(
228
+ canonicalValue.reason,
229
+ canonicalValue.error?.reason,
230
+ canonicalValue.code,
231
+ canonicalValue.error?.code,
232
+ legacyValue?.reason,
233
+ legacyValue?.error?.reason,
234
+ input.reason,
235
+ canonicalValue.error,
236
+ legacyValue?.error,
237
+ canonicalMarker,
238
+ legacyMarker
239
+ )
240
+ : pickFailureText(
241
+ input.reason,
242
+ legacyValue?.reason,
243
+ legacyValue?.error?.reason,
244
+ legacyValue?.error,
245
+ legacyMarker
246
+ ),
247
+ code: canonicalValue
248
+ ? pickFailureText(
249
+ canonicalValue.code,
250
+ canonicalValue.error?.code,
251
+ legacyValue?.code,
252
+ legacyValue?.error?.code,
253
+ input.code,
254
+ typeof canonicalValue.error === 'string' ? canonicalValue.error : null,
255
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
256
+ canonicalMarker,
257
+ legacyMarker
258
+ )
259
+ : pickFailureText(
260
+ input.code,
261
+ legacyValue?.code,
262
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
263
+ legacyMarker
264
+ ),
265
+ message: canonicalValue
266
+ ? pickFailureText(
267
+ canonicalValue.message,
268
+ canonicalValue.error?.message,
269
+ canonicalValue.reason,
270
+ canonicalValue.code,
271
+ legacyValue?.message,
272
+ legacyValue?.error?.message,
273
+ input.message,
274
+ typeof canonicalValue.error === 'string' ? canonicalValue.error : null,
275
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
276
+ canonicalMarker,
277
+ legacyMarker
278
+ )
279
+ : pickFailureText(
280
+ input.message,
281
+ legacyValue?.message,
282
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
283
+ legacyMarker
284
+ ),
285
+ // Keep the original nested objects available to the classifier so that a
286
+ // canonical value can win over a conflicting outer status/code.
287
+ __canonicalFailure: canonicalFailure,
288
+ __canonicalFailureMarker: canonicalMarker,
289
+ __legacyError: legacyError,
290
+ __legacyFailureMarker: legacyMarker,
291
+ __outerFailureEnvelope: input
292
+ };
293
+ };
294
+
295
+ const normalizeFailureClass = (value) => {
296
+ if (typeof value !== 'string') return null;
297
+ const trimmed = value.trim();
298
+ if (CLOUD_FAILURE_CLASS_VALUES.has(trimmed)) return trimmed;
299
+ const kebab = trimmed.toLowerCase().replace(/_/g, '-');
300
+ return CLOUD_FAILURE_CLASS_VALUES.has(kebab) ? kebab : null;
301
+ };
302
+
303
+ const explicitFailureClass = (...values) => values
304
+ .map((value) => normalizeFailureClass(value))
305
+ .find(Boolean);
306
+
307
+ const knownFailureCodeClass = (...values) => values
308
+ .map((value) => failureCodeClass(value))
309
+ .find(Boolean);
310
+
311
+ const normalizeFailureRetryAfterMs = (value) => {
312
+ if (value === null || value === undefined || value === '') return null;
313
+ const retryAfterMs = Number(value);
314
+ return Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : null;
315
+ };
316
+
317
+ const looksLikeNetworkFailure = (source) => {
318
+ const blob = [
319
+ source.name,
320
+ source.code,
321
+ source.reason,
322
+ source.message,
323
+ source.error?.name,
324
+ source.error?.code,
325
+ source.error?.reason,
326
+ source.error?.message
327
+ ].filter(Boolean).join(' ').toLowerCase();
328
+ return Boolean(
329
+ blob.includes('failed to fetch') ||
330
+ blob.includes('fetch failed') ||
331
+ blob.includes('network-request-failed') ||
332
+ blob.includes('network request failed') ||
333
+ blob.includes('network_error') ||
334
+ blob.includes('err_network') ||
335
+ blob.includes('econnreset') ||
336
+ blob.includes('etimedout')
337
+ );
338
+ };
339
+
340
+ const classifyRecordTimeLabelCloudFailure = (source, status) => {
341
+ const canonical = source.__canonicalFailure;
342
+ const canonicalMarker = source.__canonicalFailureMarker;
343
+ const legacy = source.__legacyError;
344
+ const legacyMarker = source.__legacyFailureMarker;
345
+ const outer = source.__outerFailureEnvelope || source;
346
+
347
+ // A canonical nested class is the strongest signal. A canonical code (or
348
+ // string marker) is next, before any compatibility field at the envelope
349
+ // level. This preserves canonical operation conflicts even when an older
350
+ // gateway leaves a contradictory retryable class beside `failure`.
351
+ const canonicalExplicitClass = explicitFailureClass(
352
+ canonical?.class,
353
+ canonical?.failureClass,
354
+ canonical?.cloudFailureClass
355
+ );
356
+ if (canonicalExplicitClass) return canonicalExplicitClass;
357
+ const canonicalCodeClass = knownFailureCodeClass(
358
+ canonical?.code,
359
+ canonical?.reason,
360
+ canonical?.error?.code,
361
+ canonical?.error?.reason,
362
+ canonical?.message,
363
+ canonical?.error?.message,
364
+ canonical?.error,
365
+ canonicalMarker
366
+ );
367
+ if (canonicalCodeClass) return canonicalCodeClass;
368
+
369
+ // These canonical boolean markers are class-bearing wire fields too. Keep
370
+ // them ahead of compatibility fields and HTTP heuristics, just like a
371
+ // canonical class/code.
372
+ if (canonical?.stale === true) {
373
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
374
+ }
375
+ if (canonical?.bootstrapRequired === true || canonical?.bootstrap_required === true) {
376
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
377
+ }
378
+
379
+ const outerExplicitClass = explicitFailureClass(
380
+ outer.class,
381
+ outer.failureClass,
382
+ outer.cloudFailureClass
383
+ );
384
+ const legacyExplicitClass = explicitFailureClass(
385
+ legacy?.class,
386
+ legacy?.failureClass,
387
+ legacy?.cloudFailureClass
388
+ );
389
+ if (outerExplicitClass || legacyExplicitClass) {
390
+ return outerExplicitClass || legacyExplicitClass;
391
+ }
392
+
393
+ const legacyCodeClass = knownFailureCodeClass(
394
+ outer.code,
395
+ outer.reason,
396
+ outer.message,
397
+ outer.error?.code,
398
+ outer.error?.reason,
399
+ outer.error?.message,
400
+ legacy?.code,
401
+ legacy?.reason,
402
+ legacy?.message,
403
+ legacy?.error?.code,
404
+ legacy?.error?.reason,
405
+ legacy?.error?.message,
406
+ legacy?.error,
407
+ legacyMarker
408
+ );
409
+ if (legacyCodeClass) return legacyCodeClass;
410
+
411
+ if (source.stale === true) {
412
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
413
+ }
414
+ if (status === 401) {
415
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED;
416
+ }
417
+ if (source.bootstrapRequired === true || source.bootstrap_required === true) {
418
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
419
+ }
420
+ const retryable = source.retryable === true || source.error?.retryable === true;
421
+ if (
422
+ status === 408 ||
423
+ status === 425 ||
424
+ status === 429 ||
425
+ status === 409 ||
426
+ (Number.isFinite(status) && status >= 500) ||
427
+ retryable ||
428
+ looksLikeNetworkFailure(source)
429
+ ) {
430
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT;
431
+ }
432
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
433
+ };
434
+
435
+ export const isRecordTimeLabelOperationConflictFailure = (input = {}) => {
436
+ const source = unwrapFailureSource(input);
437
+ const failure = normalizeRecordTimeLabelCloudFailure(input);
438
+ if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL) return false;
439
+ const canonical = source.__canonicalFailure;
440
+ const canonicalMarker = source.__canonicalFailureMarker;
441
+ const canonicalClassDecided = Boolean(
442
+ explicitFailureClass(
443
+ canonical?.class,
444
+ canonical?.failureClass,
445
+ canonical?.cloudFailureClass
446
+ ) ||
447
+ knownFailureCodeClass(
448
+ canonical?.code,
449
+ canonical?.reason,
450
+ canonical?.error?.code,
451
+ canonical?.error?.reason,
452
+ canonical?.message,
453
+ canonical?.error?.message,
454
+ canonical?.error,
455
+ canonicalMarker
456
+ ) ||
457
+ canonical?.stale === true ||
458
+ canonical?.bootstrapRequired === true ||
459
+ canonical?.bootstrap_required === true
460
+ );
461
+ // Keep conflict detection in the same precedence layer as classification:
462
+ // once canonical failure metadata selected the terminal class, legacy and
463
+ // outer compatibility fields must not turn a generic terminal into a
464
+ // quarantine-eligible operation conflict.
465
+ const values = canonicalClassDecided
466
+ ? [
467
+ canonical?.code,
468
+ canonical?.reason,
469
+ canonical?.error?.code,
470
+ canonical?.error?.reason,
471
+ canonicalMarker
472
+ ]
473
+ : [
474
+ source.__legacyError?.code,
475
+ source.__legacyError?.reason,
476
+ source.__legacyFailureMarker,
477
+ source.__outerFailureEnvelope?.code,
478
+ source.__outerFailureEnvelope?.reason,
479
+ source.__outerFailureEnvelope?.error?.code,
480
+ source.__outerFailureEnvelope?.error?.reason
481
+ ];
482
+ return values.some((value) => {
483
+ const normalized = canonicalizeFailureCode(value);
484
+ return normalized ? OPERATION_CONFLICT_CODES.has(normalized) : false;
485
+ });
486
+ };
487
+
488
+ /**
489
+ * Classify a cloud/bootstrap/catch-up failure without inspecting JWT structure
490
+ * or Firebase SDKs. Adapters may pass Firebase codes as opaque reason/code
491
+ * strings; this helper only preserves metadata and assigns one class.
492
+ */
493
+ export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
494
+ const source = unwrapFailureSource(input);
495
+ const statusValue = Number(
496
+ source.__canonicalFailure?.status ??
497
+ source.__canonicalFailure?.statusCode ??
498
+ source.__legacyError?.status ??
499
+ source.__legacyError?.statusCode ??
500
+ source.status ??
501
+ source.statusCode ??
502
+ source.error?.status
503
+ );
504
+ const status = Number.isFinite(statusValue) ? statusValue : null;
505
+ const failureClass = classifyRecordTimeLabelCloudFailure(source, status);
506
+ const reason = pickFailureText(
507
+ source.reason,
508
+ source.error?.reason,
509
+ source.code,
510
+ source.error?.code,
511
+ source.message,
512
+ source.error?.message
513
+ );
514
+ const code = pickFailureText(
515
+ source.code,
516
+ source.error?.code,
517
+ reason,
518
+ 'recordtimelabel_cloud_failure'
519
+ );
520
+ const message = pickFailureText(
521
+ source.message,
522
+ source.error?.message,
523
+ reason,
524
+ code,
525
+ 'recordtimelabel_cloud_failure'
526
+ );
527
+ const retryAfterMs = failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT
528
+ ? normalizeFailureRetryAfterMs(source.retryAfterMs ?? source.error?.retryAfterMs)
529
+ : null;
530
+ const rawSchemaVersion = source.__canonicalFailure?.schemaVersion ??
531
+ source.schemaVersion;
532
+ const schemaVersion = Number(rawSchemaVersion);
533
+ const requestId = pickFailureText(
534
+ source.__canonicalFailure?.requestId,
535
+ source.requestId,
536
+ source.error?.requestId
537
+ );
538
+ return {
539
+ schemaVersion: Number.isSafeInteger(schemaVersion) && schemaVersion > 0
540
+ ? schemaVersion
541
+ : RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
542
+ class: failureClass,
543
+ requestId,
544
+ status,
545
+ reason,
546
+ code,
547
+ message,
548
+ retryable: failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT,
549
+ retryAfterMs,
550
+ bootstrapRequired: failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED
551
+ };
552
+ };
553
+
554
+ export const toRecordTimeLabelCloudFailureError = (input = {}) => {
555
+ const failure = normalizeRecordTimeLabelCloudFailure(input);
556
+ const error = new Error(failure.message);
557
+ error.schemaVersion = failure.schemaVersion;
558
+ error.class = failure.class;
559
+ error.code = failure.code;
560
+ error.reason = failure.reason;
561
+ error.status = failure.status;
562
+ error.retryable = failure.retryable;
563
+ error.retryAfterMs = failure.retryAfterMs;
564
+ error.bootstrapRequired = failure.bootstrapRequired;
565
+ error.requestId = failure.requestId;
566
+ Object.defineProperty(error, 'failure', {
567
+ value: failure,
568
+ enumerable: false,
569
+ configurable: true
570
+ });
571
+ return error;
572
+ };
573
+
26
574
  const ALLOWED_OPERATION_RESULT_STATUSES = new Set(
27
575
  Object.values(RECORD_TIMELABEL_OPERATION_RESULT_STATUSES)
28
576
  );
@@ -136,6 +684,7 @@ const isSuccessfulEnvelope = (response) => {
136
684
  response.success === false ||
137
685
  response.ok === false ||
138
686
  response.error ||
687
+ response.failure ||
139
688
  response.code === 'error'
140
689
  ) return false;
141
690
  return response.success === true || response.ok === true || (
@@ -269,10 +818,21 @@ export default {
269
818
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
270
819
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
271
820
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
821
+ RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
822
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
823
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
824
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
825
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
826
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
272
827
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
273
828
  toRecordTimeLabelWireOperation,
274
829
  buildRecordTimeLabelRequestId,
275
830
  createRecordTimeLabelTransportFailureResults,
276
831
  normalizeRecordTimeLabelOperationResults,
277
- normalizeRecordTimeLabelEnvelopeResponse
832
+ normalizeRecordTimeLabelEnvelopeResponse,
833
+ normalizeRecordTimeLabelCloudFailure,
834
+ toRecordTimeLabelCloudFailureError,
835
+ isRecordTimeLabelOperationConflictFailure,
836
+ normalizeRecordTimeLabelImmutableId,
837
+ normalizeRecordTimeLabelPlannerId
278
838
  };