@recordtimelabel/core 0.6.2 → 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,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,97 @@ 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
+ ['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
+ ])
82
167
  ]);
83
168
 
84
169
  const pickFailureText = (...values) => {
@@ -88,17 +173,140 @@ const pickFailureText = (...values) => {
88
173
  return null;
89
174
  };
90
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
+
91
192
  const unwrapFailureSource = (input) => {
92
193
  if (input == null) return {};
93
194
  if (typeof input === 'string') return {message: input, reason: input, code: input};
94
195
  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;
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
+ };
97
293
  };
98
294
 
99
- const normalizeFailureClass = (value) => (
100
- CLOUD_FAILURE_CLASS_VALUES.has(value) ? value : null
101
- );
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);
102
310
 
103
311
  const normalizeFailureRetryAfterMs = (value) => {
104
312
  if (value === null || value === undefined || value === '') return null;
@@ -106,25 +314,7 @@ const normalizeFailureRetryAfterMs = (value) => {
106
314
  return Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : null;
107
315
  };
108
316
 
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) => {
317
+ const looksLikeNetworkFailure = (source) => {
128
318
  const blob = [
129
319
  source.name,
130
320
  source.code,
@@ -136,49 +326,97 @@ const looksLikeNetworkFailure = (source, tokens) => {
136
326
  source.error?.message
137
327
  ].filter(Boolean).join(' ').toLowerCase();
138
328
  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
329
  blob.includes('failed to fetch') ||
149
330
  blob.includes('fetch failed') ||
150
331
  blob.includes('network-request-failed') ||
151
- 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')
152
337
  );
153
338
  };
154
339
 
155
- const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
156
- const explicitClass = normalizeFailureClass(
157
- source.class ?? source.failureClass ?? source.cloudFailureClass
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
158
355
  );
159
- if (explicitClass) return explicitClass;
160
- if (
161
- source.stale === true ||
162
- tokens.some((token) => STALE_SESSION_CODES.has(token) || token === 'stale-session')
163
- ) {
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) {
164
373
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
165
374
  }
166
- if (
167
- status === 401 ||
168
- tokens.some((token) => AUTH_TRANSITION_CODES.has(token))
169
- ) {
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) {
170
415
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED;
171
416
  }
172
- if (
173
- source.bootstrapRequired === true ||
174
- source.bootstrap_required === true ||
175
- tokens.some((token) => BOOTSTRAP_REQUIRED_CODES.has(token))
176
- ) {
417
+ if (source.bootstrapRequired === true || source.bootstrap_required === true) {
177
418
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
178
419
  }
179
- if (tokens.some((token) => TERMINAL_CONFLICT_CODES.has(token))) {
180
- return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
181
- }
182
420
  const retryable = source.retryable === true || source.error?.retryable === true;
183
421
  if (
184
422
  status === 408 ||
@@ -187,13 +425,66 @@ const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
187
425
  status === 409 ||
188
426
  (Number.isFinite(status) && status >= 500) ||
189
427
  retryable ||
190
- looksLikeNetworkFailure(source, tokens)
428
+ looksLikeNetworkFailure(source)
191
429
  ) {
192
430
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT;
193
431
  }
194
432
  return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
195
433
  };
196
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
+
197
488
  /**
198
489
  * Classify a cloud/bootstrap/catch-up failure without inspecting JWT structure
199
490
  * or Firebase SDKs. Adapters may pass Firebase codes as opaque reason/code
@@ -201,10 +492,17 @@ const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
201
492
  */
202
493
  export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
203
494
  const source = unwrapFailureSource(input);
204
- const statusValue = Number(source.status ?? source.statusCode ?? source.error?.status);
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
+ );
205
504
  const status = Number.isFinite(statusValue) ? statusValue : null;
206
- const tokens = collectFailureTokens(source);
207
- const failureClass = classifyRecordTimeLabelCloudFailure(source, status, tokens);
505
+ const failureClass = classifyRecordTimeLabelCloudFailure(source, status);
208
506
  const reason = pickFailureText(
209
507
  source.reason,
210
508
  source.error?.reason,
@@ -229,8 +527,20 @@ export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
229
527
  const retryAfterMs = failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT
230
528
  ? normalizeFailureRetryAfterMs(source.retryAfterMs ?? source.error?.retryAfterMs)
231
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
+ );
232
538
  return {
539
+ schemaVersion: Number.isSafeInteger(schemaVersion) && schemaVersion > 0
540
+ ? schemaVersion
541
+ : RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
233
542
  class: failureClass,
543
+ requestId,
234
544
  status,
235
545
  reason,
236
546
  code,
@@ -244,6 +554,7 @@ export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
244
554
  export const toRecordTimeLabelCloudFailureError = (input = {}) => {
245
555
  const failure = normalizeRecordTimeLabelCloudFailure(input);
246
556
  const error = new Error(failure.message);
557
+ error.schemaVersion = failure.schemaVersion;
247
558
  error.class = failure.class;
248
559
  error.code = failure.code;
249
560
  error.reason = failure.reason;
@@ -251,6 +562,12 @@ export const toRecordTimeLabelCloudFailureError = (input = {}) => {
251
562
  error.retryable = failure.retryable;
252
563
  error.retryAfterMs = failure.retryAfterMs;
253
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
+ });
254
571
  return error;
255
572
  };
256
573
 
@@ -367,6 +684,7 @@ const isSuccessfulEnvelope = (response) => {
367
684
  response.success === false ||
368
685
  response.ok === false ||
369
686
  response.error ||
687
+ response.failure ||
370
688
  response.code === 'error'
371
689
  ) return false;
372
690
  return response.success === true || response.ok === true || (
@@ -501,6 +819,10 @@ export default {
501
819
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
502
820
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
503
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,
504
826
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
505
827
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
506
828
  toRecordTimeLabelWireOperation,
@@ -509,5 +831,8 @@ export default {
509
831
  normalizeRecordTimeLabelOperationResults,
510
832
  normalizeRecordTimeLabelEnvelopeResponse,
511
833
  normalizeRecordTimeLabelCloudFailure,
512
- toRecordTimeLabelCloudFailureError
834
+ toRecordTimeLabelCloudFailureError,
835
+ isRecordTimeLabelOperationConflictFailure,
836
+ normalizeRecordTimeLabelImmutableId,
837
+ normalizeRecordTimeLabelPlannerId
513
838
  };