@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/README.md +28 -2
- package/package.json +1 -1
- package/src/changefeed.js +67 -8
- package/src/firestore-v2.js +11 -0
- package/src/index.js +1681 -315
- package/src/protocol.js +562 -2
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
|
};
|