@recordtimelabel/core 0.6.2 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/protocol.js CHANGED
@@ -17,12 +17,54 @@ 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
24
37
  ]);
25
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
+
26
68
  export const RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES = Object.freeze({
27
69
  TRANSIENT: 'transient',
28
70
  BOOTSTRAP_REQUIRED: 'bootstrap-required',
@@ -31,54 +73,106 @@ export const RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES = Object.freeze({
31
73
  STALE_SESSION: 'stale-session'
32
74
  });
33
75
 
34
- export const RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE = 'cloud-failure-state-v1';
35
-
36
76
  const CLOUD_FAILURE_CLASS_VALUES = new Set(Object.values(RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES));
37
77
 
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'
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'
71
105
  ]);
72
106
 
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'
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
+ ['global_bootstrap_read_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
122
+ ['rate_limited', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
123
+ ['too_many_requests', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
124
+ ['network_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
125
+ ['network_request_failed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
126
+ ['bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
127
+ ['bootstraprequired', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
128
+ ['recordtimelabel_bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
129
+ ['expired_bootstrap_cursor', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
130
+ ['invalid_bootstrap_cursor', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
131
+ ['revision_gap', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
132
+ ['cache_ahead', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
133
+ ['changed_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
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],
138
+ ['stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
139
+ ['recordtimelabel_stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
140
+ ['auth_context_changed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
141
+ ['stale_uid', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
142
+ ['stale_epoch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
143
+ ['uid_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
144
+ ['workspace_epoch_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
145
+ ['auth_transition_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
146
+ ['auth_invalid', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
147
+ ['auth_revoked', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
148
+ ['auth_disabled', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
149
+ ['invalid_id_token', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
150
+ ['unauthenticated', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
151
+ ['permission_denied', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED],
152
+ // The wire class is also accepted in `code` for adapters that flatten a
153
+ // canonical failure. Keep this explicit rather than falling back to the
154
+ // HTTP status heuristic (a flattened terminal 409 must not become retryable).
155
+ ['terminal', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
156
+ ['validation_failed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
157
+ ['invalid_operation', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
158
+ ['invalid_operation_batch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
159
+ ['malformed_operation', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
160
+ ['operation_rejected', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
161
+ ['protocol_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
162
+ ['operation_result_count_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
163
+ ['lifecycle_conflict', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
164
+ ['lifecycle_generation_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
165
+ ['record_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
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],
172
+ ...[...OPERATION_CONFLICT_CODES].map((code) => [
173
+ code,
174
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL
175
+ ])
82
176
  ]);
83
177
 
84
178
  const pickFailureText = (...values) => {
@@ -88,17 +182,140 @@ const pickFailureText = (...values) => {
88
182
  return null;
89
183
  };
90
184
 
185
+ const canonicalizeFailureCode = (value) => {
186
+ if (typeof value !== 'string') return null;
187
+ const normalized = value.trim().toLowerCase()
188
+ .replace(/[/:.\-\s]+/g, '_');
189
+ return normalized || null;
190
+ };
191
+
192
+ const failureCodeClass = (value) => {
193
+ const normalized = canonicalizeFailureCode(value);
194
+ return normalized ? CLOUD_FAILURE_CODE_CLASSES.get(normalized) || null : null;
195
+ };
196
+
197
+ const asFailureObject = (value) => (
198
+ value && typeof value === 'object' && !Array.isArray(value) ? value : null
199
+ );
200
+
91
201
  const unwrapFailureSource = (input) => {
92
202
  if (input == null) return {};
93
203
  if (typeof input === 'string') return {message: input, reason: input, code: input};
94
204
  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;
205
+ const canonicalFailure = asFailureObject(input.failure);
206
+ const legacyError = asFailureObject(input.error);
207
+ const canonicalString = typeof input.failure === 'string' && input.failure.trim()
208
+ ? input.failure.trim()
209
+ : '';
210
+ const legacyString = typeof input.error === 'string' && input.error.trim()
211
+ ? input.error.trim()
212
+ : '';
213
+ if (!canonicalFailure && !legacyError && !canonicalString && !legacyString) return input;
214
+ const canonicalMarker = canonicalString || null;
215
+ const legacyMarker = legacyString || null;
216
+ const canonicalValue = canonicalFailure || (canonicalMarker ? {
217
+ message: canonicalMarker,
218
+ reason: canonicalMarker,
219
+ code: canonicalMarker
220
+ } : null);
221
+ const legacyValue = legacyError || (legacyMarker ? {
222
+ message: legacyMarker,
223
+ reason: legacyMarker,
224
+ code: legacyMarker
225
+ } : null);
226
+ // A canonical `failure` object is authoritative when an adapter also
227
+ // leaves legacy fields at the envelope level. Otherwise retain the old
228
+ // `error` object compatibility path.
229
+ return {
230
+ ...(legacyValue || {}),
231
+ ...input,
232
+ ...(legacyError ? {error: legacyError} : {}),
233
+ ...(canonicalValue || {}),
234
+ ...(canonicalFailure ? {failure: canonicalFailure} : {}),
235
+ reason: canonicalValue
236
+ ? pickFailureText(
237
+ canonicalValue.reason,
238
+ canonicalValue.error?.reason,
239
+ canonicalValue.code,
240
+ canonicalValue.error?.code,
241
+ legacyValue?.reason,
242
+ legacyValue?.error?.reason,
243
+ input.reason,
244
+ canonicalValue.error,
245
+ legacyValue?.error,
246
+ canonicalMarker,
247
+ legacyMarker
248
+ )
249
+ : pickFailureText(
250
+ input.reason,
251
+ legacyValue?.reason,
252
+ legacyValue?.error?.reason,
253
+ legacyValue?.error,
254
+ legacyMarker
255
+ ),
256
+ code: canonicalValue
257
+ ? pickFailureText(
258
+ canonicalValue.code,
259
+ canonicalValue.error?.code,
260
+ legacyValue?.code,
261
+ legacyValue?.error?.code,
262
+ input.code,
263
+ typeof canonicalValue.error === 'string' ? canonicalValue.error : null,
264
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
265
+ canonicalMarker,
266
+ legacyMarker
267
+ )
268
+ : pickFailureText(
269
+ input.code,
270
+ legacyValue?.code,
271
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
272
+ legacyMarker
273
+ ),
274
+ message: canonicalValue
275
+ ? pickFailureText(
276
+ canonicalValue.message,
277
+ canonicalValue.error?.message,
278
+ canonicalValue.reason,
279
+ canonicalValue.code,
280
+ legacyValue?.message,
281
+ legacyValue?.error?.message,
282
+ input.message,
283
+ typeof canonicalValue.error === 'string' ? canonicalValue.error : null,
284
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
285
+ canonicalMarker,
286
+ legacyMarker
287
+ )
288
+ : pickFailureText(
289
+ input.message,
290
+ legacyValue?.message,
291
+ typeof legacyValue?.error === 'string' ? legacyValue.error : null,
292
+ legacyMarker
293
+ ),
294
+ // Keep the original nested objects available to the classifier so that a
295
+ // canonical value can win over a conflicting outer status/code.
296
+ __canonicalFailure: canonicalFailure,
297
+ __canonicalFailureMarker: canonicalMarker,
298
+ __legacyError: legacyError,
299
+ __legacyFailureMarker: legacyMarker,
300
+ __outerFailureEnvelope: input
301
+ };
97
302
  };
98
303
 
99
- const normalizeFailureClass = (value) => (
100
- CLOUD_FAILURE_CLASS_VALUES.has(value) ? value : null
101
- );
304
+ const normalizeFailureClass = (value) => {
305
+ if (typeof value !== 'string') return null;
306
+ const trimmed = value.trim();
307
+ if (CLOUD_FAILURE_CLASS_VALUES.has(trimmed)) return trimmed;
308
+ const kebab = trimmed.toLowerCase().replace(/_/g, '-');
309
+ return CLOUD_FAILURE_CLASS_VALUES.has(kebab) ? kebab : null;
310
+ };
311
+
312
+ const explicitFailureClass = (...values) => values
313
+ .map((value) => normalizeFailureClass(value))
314
+ .find(Boolean);
315
+
316
+ const knownFailureCodeClass = (...values) => values
317
+ .map((value) => failureCodeClass(value))
318
+ .find(Boolean);
102
319
 
103
320
  const normalizeFailureRetryAfterMs = (value) => {
104
321
  if (value === null || value === undefined || value === '') return null;
@@ -106,25 +323,7 @@ const normalizeFailureRetryAfterMs = (value) => {
106
323
  return Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : null;
107
324
  };
108
325
 
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) => {
326
+ const looksLikeNetworkFailure = (source) => {
128
327
  const blob = [
129
328
  source.name,
130
329
  source.code,
@@ -136,49 +335,97 @@ const looksLikeNetworkFailure = (source, tokens) => {
136
335
  source.error?.message
137
336
  ].filter(Boolean).join(' ').toLowerCase();
138
337
  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
338
  blob.includes('failed to fetch') ||
149
339
  blob.includes('fetch failed') ||
150
340
  blob.includes('network-request-failed') ||
151
- blob.includes('network request failed')
341
+ blob.includes('network request failed') ||
342
+ blob.includes('network_error') ||
343
+ blob.includes('err_network') ||
344
+ blob.includes('econnreset') ||
345
+ blob.includes('etimedout')
152
346
  );
153
347
  };
154
348
 
155
- const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
156
- const explicitClass = normalizeFailureClass(
157
- source.class ?? source.failureClass ?? source.cloudFailureClass
349
+ const classifyRecordTimeLabelCloudFailure = (source, status) => {
350
+ const canonical = source.__canonicalFailure;
351
+ const canonicalMarker = source.__canonicalFailureMarker;
352
+ const legacy = source.__legacyError;
353
+ const legacyMarker = source.__legacyFailureMarker;
354
+ const outer = source.__outerFailureEnvelope || source;
355
+
356
+ // A canonical nested class is the strongest signal. A canonical code (or
357
+ // string marker) is next, before any compatibility field at the envelope
358
+ // level. This preserves canonical operation conflicts even when an older
359
+ // gateway leaves a contradictory retryable class beside `failure`.
360
+ const canonicalExplicitClass = explicitFailureClass(
361
+ canonical?.class,
362
+ canonical?.failureClass,
363
+ canonical?.cloudFailureClass
158
364
  );
159
- if (explicitClass) return explicitClass;
160
- if (
161
- source.stale === true ||
162
- tokens.some((token) => STALE_SESSION_CODES.has(token) || token === 'stale-session')
163
- ) {
365
+ if (canonicalExplicitClass) return canonicalExplicitClass;
366
+ const canonicalCodeClass = knownFailureCodeClass(
367
+ canonical?.code,
368
+ canonical?.reason,
369
+ canonical?.error?.code,
370
+ canonical?.error?.reason,
371
+ canonical?.message,
372
+ canonical?.error?.message,
373
+ canonical?.error,
374
+ canonicalMarker
375
+ );
376
+ if (canonicalCodeClass) return canonicalCodeClass;
377
+
378
+ // These canonical boolean markers are class-bearing wire fields too. Keep
379
+ // them ahead of compatibility fields and HTTP heuristics, just like a
380
+ // canonical class/code.
381
+ if (canonical?.stale === true) {
164
382
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
165
383
  }
166
- if (
167
- status === 401 ||
168
- tokens.some((token) => AUTH_TRANSITION_CODES.has(token))
169
- ) {
384
+ if (canonical?.bootstrapRequired === true || canonical?.bootstrap_required === true) {
385
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
386
+ }
387
+
388
+ const outerExplicitClass = explicitFailureClass(
389
+ outer.class,
390
+ outer.failureClass,
391
+ outer.cloudFailureClass
392
+ );
393
+ const legacyExplicitClass = explicitFailureClass(
394
+ legacy?.class,
395
+ legacy?.failureClass,
396
+ legacy?.cloudFailureClass
397
+ );
398
+ if (outerExplicitClass || legacyExplicitClass) {
399
+ return outerExplicitClass || legacyExplicitClass;
400
+ }
401
+
402
+ const legacyCodeClass = knownFailureCodeClass(
403
+ outer.code,
404
+ outer.reason,
405
+ outer.message,
406
+ outer.error?.code,
407
+ outer.error?.reason,
408
+ outer.error?.message,
409
+ legacy?.code,
410
+ legacy?.reason,
411
+ legacy?.message,
412
+ legacy?.error?.code,
413
+ legacy?.error?.reason,
414
+ legacy?.error?.message,
415
+ legacy?.error,
416
+ legacyMarker
417
+ );
418
+ if (legacyCodeClass) return legacyCodeClass;
419
+
420
+ if (source.stale === true) {
421
+ return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
422
+ }
423
+ if (status === 401) {
170
424
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED;
171
425
  }
172
- if (
173
- source.bootstrapRequired === true ||
174
- source.bootstrap_required === true ||
175
- tokens.some((token) => BOOTSTRAP_REQUIRED_CODES.has(token))
176
- ) {
426
+ if (source.bootstrapRequired === true || source.bootstrap_required === true) {
177
427
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
178
428
  }
179
- if (tokens.some((token) => TERMINAL_CONFLICT_CODES.has(token))) {
180
- return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
181
- }
182
429
  const retryable = source.retryable === true || source.error?.retryable === true;
183
430
  if (
184
431
  status === 408 ||
@@ -187,13 +434,66 @@ const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
187
434
  status === 409 ||
188
435
  (Number.isFinite(status) && status >= 500) ||
189
436
  retryable ||
190
- looksLikeNetworkFailure(source, tokens)
437
+ looksLikeNetworkFailure(source)
191
438
  ) {
192
439
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT;
193
440
  }
194
441
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
195
442
  };
196
443
 
444
+ export const isRecordTimeLabelOperationConflictFailure = (input = {}) => {
445
+ const source = unwrapFailureSource(input);
446
+ const failure = normalizeRecordTimeLabelCloudFailure(input);
447
+ if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL) return false;
448
+ const canonical = source.__canonicalFailure;
449
+ const canonicalMarker = source.__canonicalFailureMarker;
450
+ const canonicalClassDecided = Boolean(
451
+ explicitFailureClass(
452
+ canonical?.class,
453
+ canonical?.failureClass,
454
+ canonical?.cloudFailureClass
455
+ ) ||
456
+ knownFailureCodeClass(
457
+ canonical?.code,
458
+ canonical?.reason,
459
+ canonical?.error?.code,
460
+ canonical?.error?.reason,
461
+ canonical?.message,
462
+ canonical?.error?.message,
463
+ canonical?.error,
464
+ canonicalMarker
465
+ ) ||
466
+ canonical?.stale === true ||
467
+ canonical?.bootstrapRequired === true ||
468
+ canonical?.bootstrap_required === true
469
+ );
470
+ // Keep conflict detection in the same precedence layer as classification:
471
+ // once canonical failure metadata selected the terminal class, legacy and
472
+ // outer compatibility fields must not turn a generic terminal into a
473
+ // quarantine-eligible operation conflict.
474
+ const values = canonicalClassDecided
475
+ ? [
476
+ canonical?.code,
477
+ canonical?.reason,
478
+ canonical?.error?.code,
479
+ canonical?.error?.reason,
480
+ canonicalMarker
481
+ ]
482
+ : [
483
+ source.__legacyError?.code,
484
+ source.__legacyError?.reason,
485
+ source.__legacyFailureMarker,
486
+ source.__outerFailureEnvelope?.code,
487
+ source.__outerFailureEnvelope?.reason,
488
+ source.__outerFailureEnvelope?.error?.code,
489
+ source.__outerFailureEnvelope?.error?.reason
490
+ ];
491
+ return values.some((value) => {
492
+ const normalized = canonicalizeFailureCode(value);
493
+ return normalized ? OPERATION_CONFLICT_CODES.has(normalized) : false;
494
+ });
495
+ };
496
+
197
497
  /**
198
498
  * Classify a cloud/bootstrap/catch-up failure without inspecting JWT structure
199
499
  * or Firebase SDKs. Adapters may pass Firebase codes as opaque reason/code
@@ -201,10 +501,17 @@ const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
201
501
  */
202
502
  export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
203
503
  const source = unwrapFailureSource(input);
204
- const statusValue = Number(source.status ?? source.statusCode ?? source.error?.status);
504
+ const statusValue = Number(
505
+ source.__canonicalFailure?.status ??
506
+ source.__canonicalFailure?.statusCode ??
507
+ source.__legacyError?.status ??
508
+ source.__legacyError?.statusCode ??
509
+ source.status ??
510
+ source.statusCode ??
511
+ source.error?.status
512
+ );
205
513
  const status = Number.isFinite(statusValue) ? statusValue : null;
206
- const tokens = collectFailureTokens(source);
207
- const failureClass = classifyRecordTimeLabelCloudFailure(source, status, tokens);
514
+ const failureClass = classifyRecordTimeLabelCloudFailure(source, status);
208
515
  const reason = pickFailureText(
209
516
  source.reason,
210
517
  source.error?.reason,
@@ -229,8 +536,20 @@ export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
229
536
  const retryAfterMs = failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT
230
537
  ? normalizeFailureRetryAfterMs(source.retryAfterMs ?? source.error?.retryAfterMs)
231
538
  : null;
539
+ const rawSchemaVersion = source.__canonicalFailure?.schemaVersion ??
540
+ source.schemaVersion;
541
+ const schemaVersion = Number(rawSchemaVersion);
542
+ const requestId = pickFailureText(
543
+ source.__canonicalFailure?.requestId,
544
+ source.requestId,
545
+ source.error?.requestId
546
+ );
232
547
  return {
548
+ schemaVersion: Number.isSafeInteger(schemaVersion) && schemaVersion > 0
549
+ ? schemaVersion
550
+ : RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
233
551
  class: failureClass,
552
+ requestId,
234
553
  status,
235
554
  reason,
236
555
  code,
@@ -244,6 +563,7 @@ export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
244
563
  export const toRecordTimeLabelCloudFailureError = (input = {}) => {
245
564
  const failure = normalizeRecordTimeLabelCloudFailure(input);
246
565
  const error = new Error(failure.message);
566
+ error.schemaVersion = failure.schemaVersion;
247
567
  error.class = failure.class;
248
568
  error.code = failure.code;
249
569
  error.reason = failure.reason;
@@ -251,6 +571,12 @@ export const toRecordTimeLabelCloudFailureError = (input = {}) => {
251
571
  error.retryable = failure.retryable;
252
572
  error.retryAfterMs = failure.retryAfterMs;
253
573
  error.bootstrapRequired = failure.bootstrapRequired;
574
+ error.requestId = failure.requestId;
575
+ Object.defineProperty(error, 'failure', {
576
+ value: failure,
577
+ enumerable: false,
578
+ configurable: true
579
+ });
254
580
  return error;
255
581
  };
256
582
 
@@ -367,6 +693,7 @@ const isSuccessfulEnvelope = (response) => {
367
693
  response.success === false ||
368
694
  response.ok === false ||
369
695
  response.error ||
696
+ response.failure ||
370
697
  response.code === 'error'
371
698
  ) return false;
372
699
  return response.success === true || response.ok === true || (
@@ -501,6 +828,10 @@ export default {
501
828
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
502
829
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
503
830
  RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
831
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
832
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
833
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
834
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
504
835
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
505
836
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
506
837
  toRecordTimeLabelWireOperation,
@@ -509,5 +840,8 @@ export default {
509
840
  normalizeRecordTimeLabelOperationResults,
510
841
  normalizeRecordTimeLabelEnvelopeResponse,
511
842
  normalizeRecordTimeLabelCloudFailure,
512
- toRecordTimeLabelCloudFailureError
843
+ toRecordTimeLabelCloudFailureError,
844
+ isRecordTimeLabelOperationConflictFailure,
845
+ normalizeRecordTimeLabelImmutableId,
846
+ normalizeRecordTimeLabelPlannerId
513
847
  };