@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/README.md +31 -11
- package/package.json +1 -1
- package/src/changefeed.js +67 -8
- package/src/firestore-v2.js +11 -0
- package/src/index.js +1922 -390
- package/src/protocol.js +438 -104
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
'
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'
|
|
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
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
156
|
-
const
|
|
157
|
-
|
|
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 (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
};
|