@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/README.md +18 -10
- package/package.json +1 -1
- package/src/changefeed.js +67 -8
- package/src/firestore-v2.js +11 -0
- package/src/index.js +1562 -294
- package/src/protocol.js +429 -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,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
|
-
|
|
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
|
+
['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
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
156
|
-
const
|
|
157
|
-
|
|
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 (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
};
|