@apifuse/provider-sdk 2.2.0-beta.59 → 2.2.0-beta.61

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.
Files changed (124) hide show
  1. package/AUTHORING.md +33 -16
  2. package/CHANGELOG.md +8 -0
  3. package/bin/apifuse-dev.ts +7 -8
  4. package/bin/apifuse-pack-types.ts +22 -0
  5. package/bin/apifuse-perf.ts +45 -10
  6. package/bin/apifuse-record.ts +6 -8
  7. package/bin/apifuse-submit-check.ts +2 -2
  8. package/dist/config/loader.js +13 -12
  9. package/dist/define.d.ts +3 -2
  10. package/dist/define.js +1 -1
  11. package/dist/dev.js +3 -1
  12. package/dist/engine.d.ts +7 -6
  13. package/dist/engine.js +6 -6
  14. package/dist/error-resolution.js +21 -4
  15. package/dist/errors.d.ts +3 -0
  16. package/dist/errors.js +9 -0
  17. package/dist/handle-meta.d.ts +39 -0
  18. package/dist/handle-meta.js +29 -0
  19. package/dist/handle.d.ts +187 -0
  20. package/dist/handle.js +254 -0
  21. package/dist/i18n/catalog.js +21 -1
  22. package/dist/index.d.ts +6 -4
  23. package/dist/index.js +4 -3
  24. package/dist/lint.d.ts +3 -0
  25. package/dist/lint.js +388 -62
  26. package/dist/provider.d.ts +4 -4
  27. package/dist/provider.js +3 -3
  28. package/dist/runtime/browser.js +2 -1
  29. package/dist/runtime/cache.js +3 -2
  30. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  31. package/dist/runtime/diagnostic-env-values.js +57 -0
  32. package/dist/runtime/diagnostic-env.d.ts +11 -0
  33. package/dist/runtime/diagnostic-env.js +35 -0
  34. package/dist/runtime/diagnostic-redactor.d.ts +49 -0
  35. package/dist/runtime/diagnostic-redactor.js +569 -0
  36. package/dist/runtime/egress-lease.d.ts +2 -0
  37. package/dist/runtime/egress-lease.js +4 -1
  38. package/dist/runtime/env.js +2 -1
  39. package/dist/runtime/handle-wordlist.d.ts +43 -0
  40. package/dist/runtime/{choice-wordlist.js → handle-wordlist.js} +127 -9
  41. package/dist/runtime/handle.d.ts +71 -0
  42. package/dist/runtime/handle.js +895 -0
  43. package/dist/runtime/instrumentation.js +3 -0
  44. package/dist/runtime/native-network.d.ts +10 -1
  45. package/dist/runtime/native-network.js +230 -29
  46. package/dist/runtime/native-telemetry.d.ts +142 -0
  47. package/dist/runtime/native-telemetry.js +231 -0
  48. package/dist/runtime/ocr.js +2 -1
  49. package/dist/runtime/otlp.js +5 -4
  50. package/dist/runtime/proxy-nodemaven.js +3 -2
  51. package/dist/runtime/request-options.d.ts +7 -0
  52. package/dist/runtime/request-options.js +62 -22
  53. package/dist/runtime/resolver-vendors/browser.js +0 -1
  54. package/dist/runtime/resolver.js +3 -2
  55. package/dist/runtime/state.d.ts +4 -1
  56. package/dist/runtime/state.js +32 -17
  57. package/dist/runtime/stt.js +3 -2
  58. package/dist/runtime/trace.d.ts +5 -0
  59. package/dist/runtime/trace.js +115 -20
  60. package/dist/schema.d.ts +11 -0
  61. package/dist/schema.js +56 -2
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.js +1 -0
  64. package/dist/server/self-test-redaction.js +8 -33
  65. package/dist/server/sensitive-values.d.ts +26 -0
  66. package/dist/server/sensitive-values.js +128 -0
  67. package/dist/server/serve-implementation.d.ts +4 -11
  68. package/dist/server/serve-implementation.js +305 -96
  69. package/dist/server/trace-output.d.ts +2 -1
  70. package/dist/server/trace-output.js +46 -14
  71. package/dist/testing/run.js +10 -7
  72. package/dist/trace-sanitization.d.ts +4 -3
  73. package/dist/trace-sanitization.js +45 -22
  74. package/dist/types.d.ts +4 -107
  75. package/package.json +2 -1
  76. package/src/config/loader.ts +13 -12
  77. package/src/define.ts +4 -3
  78. package/src/dev.ts +3 -1
  79. package/src/engine.ts +8 -8
  80. package/src/error-resolution.ts +21 -4
  81. package/src/errors.ts +13 -0
  82. package/src/handle-meta.ts +58 -0
  83. package/src/handle.ts +529 -0
  84. package/src/i18n/catalog.ts +23 -1
  85. package/src/index.ts +28 -13
  86. package/src/lint.ts +456 -71
  87. package/src/provider.ts +10 -20
  88. package/src/runtime/browser.ts +2 -1
  89. package/src/runtime/cache.ts +3 -2
  90. package/src/runtime/diagnostic-env-values.ts +56 -0
  91. package/src/runtime/diagnostic-env.ts +55 -0
  92. package/src/runtime/diagnostic-redactor.ts +662 -0
  93. package/src/runtime/egress-lease.ts +6 -1
  94. package/src/runtime/env.ts +2 -1
  95. package/src/runtime/{choice-wordlist.ts → handle-wordlist.ts} +130 -9
  96. package/src/runtime/handle.ts +1162 -0
  97. package/src/runtime/instrumentation.ts +3 -0
  98. package/src/runtime/native-network.ts +281 -33
  99. package/src/runtime/native-telemetry.ts +366 -0
  100. package/src/runtime/ocr.ts +2 -1
  101. package/src/runtime/otlp.ts +5 -4
  102. package/src/runtime/proxy-nodemaven.ts +3 -2
  103. package/src/runtime/request-options.ts +76 -28
  104. package/src/runtime/resolver-vendors/browser.ts +0 -1
  105. package/src/runtime/resolver.ts +3 -2
  106. package/src/runtime/state.ts +44 -16
  107. package/src/runtime/stt.ts +3 -2
  108. package/src/runtime/trace.ts +168 -26
  109. package/src/schema.ts +63 -2
  110. package/src/server/index.ts +17 -0
  111. package/src/server/self-test-redaction.ts +10 -40
  112. package/src/server/sensitive-values.ts +197 -0
  113. package/src/server/serve-implementation.ts +388 -128
  114. package/src/server/trace-output.ts +77 -17
  115. package/src/testing/run.ts +11 -7
  116. package/src/trace-sanitization.ts +70 -23
  117. package/src/types.ts +4 -137
  118. package/dist/choice-token.d.ts +0 -24
  119. package/dist/choice-token.js +0 -74
  120. package/dist/runtime/choice-wordlist.d.ts +0 -9
  121. package/dist/runtime/choice.d.ts +0 -27
  122. package/dist/runtime/choice.js +0 -801
  123. package/src/choice-token.ts +0 -165
  124. package/src/runtime/choice.ts +0 -1197
@@ -0,0 +1,895 @@
1
+ /**
2
+ * LLM-friendly handles (ADR-0012): runtime (`ctx.handle`).
3
+ *
4
+ * Storage layout: one state namespace per kind, `handle.<kind>`, scoped by the
5
+ * kind's access (bound → connection scope via `state.forConnection`, public →
6
+ * provider scope). The state key is the canonical handle string. Records hold
7
+ * the validated data plus status/timestamps; the handle string itself carries
8
+ * no payload and no signature.
9
+ */
10
+ import { randomInt, randomUUID } from "node:crypto";
11
+ import { isProviderError } from "../errors.js";
12
+ import { HandleError, handleRecoverySentence, isDraftKind, } from "../handle.js";
13
+ import { BOUND_HANDLE_WORD_COUNT, findHandleWordWithinDistance, HIGH_PUBLIC_HANDLE_WORD_COUNT, ISSUABLE_HANDLE_WORDS, PUBLIC_HANDLE_WORD_COUNT, } from "./handle-wordlist.js";
14
+ import { createMemoryProviderRuntimeState } from "./state.js";
15
+ const RECORD_VERSION = 1;
16
+ const CREATE_ATTEMPTS = 5;
17
+ /** Minimum time a `committing` claim survives while upstream work runs. */
18
+ const COMMIT_LEASE_MS = 5 * 60_000;
19
+ const UPDATE_ATTEMPTS = 3;
20
+ const DISCARD_ATTEMPTS = 3;
21
+ /** Upper bound on how long an expired record is kept so it can report HANDLE_EXPIRED. */
22
+ const EXPIRY_GRACE_MAX_MS = 60 * 60_000;
23
+ const COMMIT_FINALIZE_RETRY_DELAY_MS = 50;
24
+ const COMMIT_FINALIZE_ATTEMPTS = 3;
25
+ const NAMESPACE_PREFIX = "handle.";
26
+ // ---------------------------------------------------------------------------
27
+ // Normalization (spec §4)
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Security argument: normalization is a deterministic many-to-one map from raw
31
+ * input onto exactly one canonical key. One attempt still tests exactly one
32
+ * key, so the ADR 0006 §2 guessing numbers for public 4/5-word handles are
33
+ * unchanged. Distance-1 word repair cannot widen the target set because the
34
+ * wordlist has pairwise edit distance >= 3: the distance-1 neighbourhoods of
35
+ * two different words never overlap, so a segment resolves to at most one
36
+ * word. Bound 2-word handles need no guessing analysis at all (other
37
+ * connections cannot address them).
38
+ */
39
+ // None of these characters can occur inside a canonical handle, so stripping
40
+ // any run of them at either end (balanced or not — the 2026-08-21 "trailing
41
+ // quote" shape was unbalanced) cannot change which key is addressed.
42
+ const LEADING_WRAPPING = /^["'`<([{\s]+/;
43
+ const TRAILING_WRAPPING = /["'`>)\]}\s.,;:!?]+$/;
44
+ const KIND_SEPARATOR = /^[_\-:\s]+/;
45
+ const WORD_SEPARATOR = /[-_\s]+/;
46
+ function stripWrapping(value) {
47
+ const text = value.replace(LEADING_WRAPPING, "").replace(TRAILING_WRAPPING, "");
48
+ return { text, stripped: text !== value };
49
+ }
50
+ function classifySeparator(actual, expected, classes) {
51
+ if (actual === expected)
52
+ return;
53
+ if (/\s/.test(actual))
54
+ classes.add("whitespace");
55
+ if (actual.replace(/\s+/g, "") !== expected)
56
+ classes.add("separator");
57
+ }
58
+ function acceptedWordCounts(kind) {
59
+ // Public cursors accept both 4 and 5 words so a kind can move between
60
+ // standard and high strength without invalidating handles in flight.
61
+ if (kind.access === "public")
62
+ return [PUBLIC_HANDLE_WORD_COUNT, HIGH_PUBLIC_HANDLE_WORD_COUNT];
63
+ return [BOUND_HANDLE_WORD_COUNT];
64
+ }
65
+ /**
66
+ * Longest input the normalizer will look at. A canonical handle is at most
67
+ * 12 (kind) + 1 + 5 × 10 + 4 (words and hyphens) = 67 characters; the budget
68
+ * leaves room for wrapping punctuation and stray whitespace while keeping the
69
+ * synchronous normalization path bounded against arbitrary client input.
70
+ */
71
+ const MAX_HANDLE_INPUT_LENGTH = 256;
72
+ function normalizeHandleInternal(kind, raw) {
73
+ const classes = new Set();
74
+ if (raw.length > MAX_HANDLE_INPUT_LENGTH) {
75
+ return { ok: false, reason: "invalid", detail: "input too long" };
76
+ }
77
+ // 1. trim; strip wrapping quotes/backticks/brackets and trailing punctuation.
78
+ const trimmed = raw.trim();
79
+ if (trimmed !== raw)
80
+ classes.add("whitespace");
81
+ const { text: unwrapped, stripped } = stripWrapping(trimmed);
82
+ if (stripped)
83
+ classes.add("punctuation");
84
+ // 2. lowercase.
85
+ const lowered = unwrapped.toLowerCase();
86
+ if (lowered !== unwrapped)
87
+ classes.add("case");
88
+ // 3. must start with the kind name followed by 1+ separators.
89
+ if (!lowered.startsWith(kind.name))
90
+ return { ok: false, reason: "kind_mismatch" };
91
+ const afterKind = lowered.slice(kind.name.length);
92
+ const kindSeparator = KIND_SEPARATOR.exec(afterKind)?.[0];
93
+ if (kindSeparator === undefined) {
94
+ if (afterKind.length === 0)
95
+ return { ok: false, reason: "invalid", detail: "missing words" };
96
+ // e.g. kind "page" against "pages_..." — a different (longer) kind name.
97
+ return { ok: false, reason: "kind_mismatch" };
98
+ }
99
+ classifySeparator(kindSeparator, "_", classes);
100
+ const body = afterKind.slice(kindSeparator.length);
101
+ if (body.length === 0)
102
+ return { ok: false, reason: "invalid", detail: "missing words" };
103
+ // 4. split on separators; resolve each segment exactly or at edit distance 1.
104
+ const segments = body.split(WORD_SEPARATOR);
105
+ const separators = body.match(new RegExp(WORD_SEPARATOR.source, "g")) ?? [];
106
+ if (segments.some((segment) => segment.length === 0)) {
107
+ return { ok: false, reason: "invalid", detail: "trailing separator" };
108
+ }
109
+ for (const separator of separators)
110
+ classifySeparator(separator, "-", classes);
111
+ const words = [];
112
+ for (const segment of segments) {
113
+ const word = findHandleWordWithinDistance(segment, 1);
114
+ if (word === null)
115
+ return { ok: false, reason: "invalid", detail: "unknown word" };
116
+ if (word !== segment)
117
+ classes.add("typo");
118
+ words.push(word);
119
+ }
120
+ // 5. word count must match the kind.
121
+ if (!acceptedWordCounts(kind).includes(words.length)) {
122
+ return { ok: false, reason: "invalid", detail: `expected ${kind.wordCount} words` };
123
+ }
124
+ // 6. canonical form.
125
+ return {
126
+ ok: true,
127
+ canonical: `${kind.name}_${words.join("-")}`,
128
+ words,
129
+ normalization: Array.from(classes).sort(),
130
+ };
131
+ }
132
+ /**
133
+ * Tolerant parse of a raw handle string into its canonical form. Throws
134
+ * `HANDLE_KIND_MISMATCH` when the string does not start with the kind name and
135
+ * `HANDLE_INVALID` when the body cannot be resolved to the kind's word count.
136
+ */
137
+ export function normalizeHandle(kind, raw) {
138
+ const outcome = normalizeHandleInternal(kind, raw);
139
+ if (!outcome.ok)
140
+ throw normalizationError(kind, outcome);
141
+ return {
142
+ canonical: outcome.canonical,
143
+ words: [...outcome.words],
144
+ normalization: [...outcome.normalization],
145
+ };
146
+ }
147
+ export function formatNormalizationClasses(classes) {
148
+ return classes.length === 0 ? "none" : [...classes].sort().join("+");
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Errors (spec §5)
152
+ // ---------------------------------------------------------------------------
153
+ function publicCollapsedMessage(kind) {
154
+ const again = kind.issuedBy
155
+ ? `Call \`${kind.issuedBy}\` again to get a new one.`
156
+ : "Request a new one.";
157
+ return `\`${kind.fieldName}\` is not valid or has expired. ${again}`;
158
+ }
159
+ function normalizationError(kind, failure) {
160
+ if (failure.reason === "kind_mismatch") {
161
+ return new HandleError("HANDLE_KIND_MISMATCH", `\`${kind.fieldName}\` must be a \`${kind.name}_...\` handle as returned by ${kind.issuedBy ? `\`${kind.issuedBy}\`` : "the issuing operation"}. ${handleRecoverySentence(kind)}`, { kind: kind.name, details: { field: kind.fieldName } });
162
+ }
163
+ if (kind.access === "public") {
164
+ return new HandleError("HANDLE_INVALID", publicCollapsedMessage(kind), {
165
+ kind: kind.name,
166
+ details: { field: kind.fieldName },
167
+ });
168
+ }
169
+ return new HandleError("HANDLE_INVALID", `\`${kind.fieldName}\` is not a well-formed \`${kind.name}\` handle (${failure.detail}). ${handleRecoverySentence(kind)}`, { kind: kind.name, details: { field: kind.fieldName } });
170
+ }
171
+ function notFoundError(kind) {
172
+ if (kind.access === "public") {
173
+ return new HandleError("HANDLE_INVALID", publicCollapsedMessage(kind), {
174
+ kind: kind.name,
175
+ details: { field: kind.fieldName },
176
+ });
177
+ }
178
+ return new HandleError("HANDLE_NOT_FOUND", `\`${kind.fieldName}\` was not found; it may have expired or belong to another session. ${handleRecoverySentence(kind)}`, { kind: kind.name, details: { field: kind.fieldName } });
179
+ }
180
+ /** Which deadline a present record missed; drives the wording of HANDLE_EXPIRED. */
181
+ function expiredDeadline(kind, record, now) {
182
+ if (record.status === "committed")
183
+ return "result";
184
+ if (!isDraftKind(kind))
185
+ return "ttl";
186
+ return now >= record.max_expires_at_ms ? "max" : "idle";
187
+ }
188
+ function expiredError(kind, deadline = "ttl") {
189
+ if (kind.access === "public") {
190
+ return new HandleError("HANDLE_INVALID", publicCollapsedMessage(kind), {
191
+ kind: kind.name,
192
+ details: { field: kind.fieldName },
193
+ });
194
+ }
195
+ const lifetimeMs = isDraftKind(kind)
196
+ ? deadline === "idle"
197
+ ? kind.idleTtlMs
198
+ : deadline === "result"
199
+ ? kind.resultTtlMs
200
+ : kind.maxTtlMs
201
+ : kind.ttlMs;
202
+ const minutes = Math.max(1, Math.round(lifetimeMs / 60_000));
203
+ const reason = deadline === "idle"
204
+ ? `expired after ${minutes} minutes of inactivity`
205
+ : deadline === "result"
206
+ ? `was completed more than ${minutes} minutes ago and its result is no longer available`
207
+ : `expired after ${minutes} minutes`;
208
+ return new HandleError("HANDLE_EXPIRED", `\`${kind.fieldName}\` ${reason}. ${handleRecoverySentence(kind)}`, {
209
+ kind: kind.name,
210
+ details: { field: kind.fieldName, deadline, lifetimeMinutes: minutes },
211
+ });
212
+ }
213
+ function busyError(kind) {
214
+ return new HandleError("HANDLE_BUSY", `\`${kind.fieldName}\` is being committed by another request. Retry shortly with the same \`${kind.fieldName}\`.`, { kind: kind.name, details: { field: kind.fieldName } });
215
+ }
216
+ function committedError(kind) {
217
+ return new HandleError("HANDLE_COMMITTED", `\`${kind.fieldName}\` has already been committed and can no longer be changed. ${handleRecoverySentence(kind)}`, { kind: kind.name, details: { field: kind.fieldName } });
218
+ }
219
+ function connectionRequiredError(kind, operation) {
220
+ return new HandleError("HANDLE_CONNECTION_REQUIRED", `Handle kind "${kind.name}" is bound to a connection, but request.connectionId is missing for ${operation}. Bound handles require a connection-scoped request; use access: "public" for connection-less cursors.`, { kind: kind.name });
221
+ }
222
+ function storageUnavailableError(kind, reason, cause) {
223
+ return new HandleError("HANDLE_STORAGE_UNAVAILABLE", `Handle storage for kind "${kind.name}" is not available: ${reason}.`, { kind: kind.name, cause, details: { reason } });
224
+ }
225
+ function tooLargeError(kind, bytes) {
226
+ return new HandleError("HANDLE_TOO_LARGE", `Handle record for kind "${kind.name}" is ${bytes} bytes, above maxValueBytes ${kind.maxValueBytes}. Store less in the handle or raise maxValueBytes on the kind.`, { kind: kind.name, details: { bytes, maxValueBytes: kind.maxValueBytes } });
227
+ }
228
+ function invalidDataError(kind, operation, cause) {
229
+ const summary = cause !== null && typeof cause === "object" && "message" in cause
230
+ ? String(cause.message).split("\n")[0]
231
+ : "schema validation failed";
232
+ return new HandleError("HANDLE_INVALID_DATA", `Handle kind "${kind.name}" ${operation} received data that does not satisfy its schema (${summary}). This is a provider bug: validate or shape the data before storing it.`, {
233
+ kind: kind.name,
234
+ cause: cause instanceof Error ? cause : undefined,
235
+ details: { operation, issues: extractIssues(cause) },
236
+ });
237
+ }
238
+ function extractIssues(cause) {
239
+ if (cause !== null && typeof cause === "object" && "issues" in cause) {
240
+ return cause.issues;
241
+ }
242
+ return undefined;
243
+ }
244
+ // ---------------------------------------------------------------------------
245
+ // Runtime
246
+ // ---------------------------------------------------------------------------
247
+ function msDuration(ms) {
248
+ return `${Math.max(1, Math.floor(ms))}ms`;
249
+ }
250
+ function generateWords(count) {
251
+ return Array.from({ length: count }, () => ISSUABLE_HANDLE_WORDS[randomInt(ISSUABLE_HANDLE_WORDS.length)]);
252
+ }
253
+ function isStoredHandleRecord(value) {
254
+ if (value === null || typeof value !== "object" || Array.isArray(value))
255
+ return false;
256
+ const record = value;
257
+ return (record.v === RECORD_VERSION &&
258
+ typeof record.kind === "string" &&
259
+ (record.type === "cursor" || record.type === "draft") &&
260
+ (record.status === "active" ||
261
+ record.status === "committing" ||
262
+ record.status === "committed" ||
263
+ record.status === "discarded") &&
264
+ "data" in record &&
265
+ typeof record.created_at_ms === "number" &&
266
+ typeof record.expires_at_ms === "number" &&
267
+ typeof record.max_expires_at_ms === "number");
268
+ }
269
+ function recordBytes(record) {
270
+ return Buffer.byteLength(JSON.stringify(record), "utf8");
271
+ }
272
+ function isStateUnsupportedError(error) {
273
+ return isProviderError(error) && error.code === "PROVIDER_STATE_UNSUPPORTED";
274
+ }
275
+ /** Returns the path of the first value JSON cannot represent faithfully, or undefined. */
276
+ function findNonJsonSafePath(value, path) {
277
+ if (value === null)
278
+ return undefined;
279
+ switch (typeof value) {
280
+ case "string":
281
+ case "boolean":
282
+ return undefined;
283
+ case "number":
284
+ return Number.isFinite(value) ? undefined : path;
285
+ case "undefined":
286
+ // Dropped by JSON.stringify; the schema decides whether that is acceptable.
287
+ return undefined;
288
+ case "object": {
289
+ if (Array.isArray(value)) {
290
+ for (const [index, entry] of value.entries()) {
291
+ if (entry === undefined)
292
+ return `${path}[${index}]`;
293
+ const nested = findNonJsonSafePath(entry, `${path}[${index}]`);
294
+ if (nested)
295
+ return nested;
296
+ }
297
+ return undefined;
298
+ }
299
+ const proto = Object.getPrototypeOf(value);
300
+ if (proto !== Object.prototype && proto !== null)
301
+ return path;
302
+ for (const [key, entry] of Object.entries(value)) {
303
+ const nested = findNonJsonSafePath(entry, `${path}.${key}`);
304
+ if (nested)
305
+ return nested;
306
+ }
307
+ return undefined;
308
+ }
309
+ default:
310
+ return path;
311
+ }
312
+ }
313
+ export function createHandleContext(options) {
314
+ const nowMs = options.nowMs ?? (() => Date.now());
315
+ const commitLeaseMs = Math.max(1, options.commitLeaseMs ?? COMMIT_LEASE_MS);
316
+ const connectionId = options.request?.connectionId;
317
+ function emit(kind, operation, outcome, normalization, words) {
318
+ if (!options.onTelemetry)
319
+ return;
320
+ try {
321
+ options.onTelemetry({
322
+ providerId: options.providerId,
323
+ kind: kind.name,
324
+ type: kind.type,
325
+ operation,
326
+ outcome,
327
+ normalization: formatNormalizationClasses(normalization),
328
+ words,
329
+ });
330
+ }
331
+ catch {
332
+ // Telemetry must never affect the operation outcome.
333
+ }
334
+ }
335
+ function outcomeForError(error) {
336
+ if (!(error instanceof HandleError))
337
+ return "error";
338
+ switch (error.code) {
339
+ case "HANDLE_NOT_FOUND":
340
+ return "not_found";
341
+ case "HANDLE_EXPIRED":
342
+ return "expired";
343
+ case "HANDLE_INVALID":
344
+ return "invalid";
345
+ case "HANDLE_KIND_MISMATCH":
346
+ return "kind_mismatch";
347
+ case "HANDLE_BUSY":
348
+ return "busy";
349
+ default:
350
+ return "error";
351
+ }
352
+ }
353
+ /** Runs an operation and emits exactly one telemetry event for it. */
354
+ async function instrumented(kind, operation, run) {
355
+ let normalization = [];
356
+ let words = 0;
357
+ const report = (nextNormalization, nextWords) => {
358
+ normalization = nextNormalization;
359
+ words = nextWords;
360
+ };
361
+ try {
362
+ const result = await run(report);
363
+ emit(kind, operation, result.outcome, normalization, words);
364
+ return result.value;
365
+ }
366
+ catch (error) {
367
+ emit(kind, operation, outcomeForError(error), normalization, words);
368
+ throw error;
369
+ }
370
+ }
371
+ function resolveNamespace(kind, operation) {
372
+ if (!options.state) {
373
+ throw storageUnavailableError(kind, "no runtime state was provided to ctx.handle");
374
+ }
375
+ const bound = kind.access === "bound";
376
+ if (bound && connectionId === undefined) {
377
+ throw connectionRequiredError(kind, operation);
378
+ }
379
+ const state = bound ? options.state.forConnection(connectionId) : options.state;
380
+ // Storage ttl = logical remaining lifetime + expiry grace; drafts also need
381
+ // room for the commit lease (see COMMIT_LEASE_MS).
382
+ const maxTtlMs = isDraftKind(kind)
383
+ ? Math.max(kind.maxTtlMs + graceMs(kind.maxTtlMs), kind.resultTtlMs + graceMs(kind.resultTtlMs), commitLeaseMs)
384
+ : kind.ttlMs + graceMs(kind.ttlMs);
385
+ const defaultTtlMs = isDraftKind(kind) ? kind.idleTtlMs : kind.ttlMs;
386
+ try {
387
+ return state.namespace(`${NAMESPACE_PREFIX}${kind.name}`, {
388
+ scope: bound ? "connection" : "provider",
389
+ defaultTtl: msDuration(defaultTtlMs),
390
+ maxTtl: msDuration(maxTtlMs),
391
+ maxEntries: kind.maxEntries,
392
+ maxValueBytes: kind.maxValueBytes,
393
+ });
394
+ }
395
+ catch (error) {
396
+ throw translateStorageError(kind, error);
397
+ }
398
+ }
399
+ function translateStorageError(kind, error) {
400
+ if (isStateUnsupportedError(error)) {
401
+ const reason = /quota exceeded/i.test(error.message)
402
+ ? "live-entry quota exceeded (maxEntries)"
403
+ : error.message;
404
+ return storageUnavailableError(kind, reason, error);
405
+ }
406
+ return error;
407
+ }
408
+ async function storage(kind, run) {
409
+ try {
410
+ return await run();
411
+ }
412
+ catch (error) {
413
+ throw translateStorageError(kind, error);
414
+ }
415
+ }
416
+ function validateData(kind, operation, data) {
417
+ const parsed = kind.schema.safeParse(data);
418
+ if (!parsed.success)
419
+ throw invalidDataError(kind, operation, parsed.error);
420
+ if (parsed.data === undefined) {
421
+ // JSON drops an undefined property, so the record would come back
422
+ // without `data` and fail its shape check on the next read.
423
+ throw invalidDataError(kind, operation, new Error("data must not be undefined"));
424
+ }
425
+ // Records are persisted as JSON (Redis). Values that JSON cannot represent
426
+ // (Date, Map, Set, class instances, NaN, bigint, functions) would silently
427
+ // come back as strings or empty objects and violate the schema's output
428
+ // type on read, so they are rejected at write time instead.
429
+ const unsafePath = findNonJsonSafePath(parsed.data, "data");
430
+ if (unsafePath) {
431
+ throw invalidDataError(kind, operation, new Error(`${unsafePath} is not JSON-safe (Date, Map, Set, class instances, NaN, bigint and functions cannot be stored)`));
432
+ }
433
+ // Detach from the caller's object graph so later mutation of the input (or,
434
+ // with memory state, of the stored reference) cannot rewrite the record.
435
+ return detach(parsed.data);
436
+ }
437
+ /** Deep copy handed to callbacks and returned from reads so provider code cannot alias stored state. */
438
+ function detach(value) {
439
+ return value === undefined ? value : structuredClone(value);
440
+ }
441
+ /** Grace window after logical expiry during which HANDLE_EXPIRED is still reportable. */
442
+ function graceMs(lifetimeMs) {
443
+ return Math.min(lifetimeMs, EXPIRY_GRACE_MAX_MS);
444
+ }
445
+ /** Logical expiry of an active draft after a touch: `min(now + idle, max_expires_at_ms)`. */
446
+ function draftExpiresAtMs(kind, record, now) {
447
+ return Math.min(now + kind.idleTtlMs, record.max_expires_at_ms);
448
+ }
449
+ /**
450
+ * Physical storage ttl for a record: the time until it can no longer report
451
+ * HANDLE_EXPIRED. Drafts live to their hard lifetime (small, per-connection
452
+ * quota) so idle expiry stays distinguishable until `max` + grace.
453
+ */
454
+ function storageTtlMs(kind, record, now) {
455
+ if (record.status === "committed") {
456
+ const lifetimeMs = isDraftKind(kind) ? kind.resultTtlMs : kind.ttlMs;
457
+ return Math.max(1, record.expires_at_ms - now) + graceMs(lifetimeMs);
458
+ }
459
+ if (isDraftKind(kind)) {
460
+ return Math.max(1, record.max_expires_at_ms - now) + graceMs(kind.maxTtlMs);
461
+ }
462
+ return Math.max(1, record.expires_at_ms - now) + graceMs(kind.ttlMs);
463
+ }
464
+ function assertSize(kind, record) {
465
+ const bytes = recordBytes(record);
466
+ if (bytes > kind.maxValueBytes)
467
+ throw tooLargeError(kind, bytes);
468
+ }
469
+ function toHandleRecord(kind, canonical, record) {
470
+ return {
471
+ handle: canonical,
472
+ kind: kind.name,
473
+ status: record.status,
474
+ data: detach(record.data),
475
+ ...(record.status === "committed" ? { result: detach(record.result) } : {}),
476
+ createdAt: new Date(record.created_at_ms).toISOString(),
477
+ expiresAt: new Date(record.expires_at_ms).toISOString(),
478
+ };
479
+ }
480
+ /**
481
+ * Normalizes, resolves storage, and loads the record, applying the spec §5
482
+ * expiry classification: absent → NOT_FOUND, present past `max_expires_at_ms`
483
+ * → EXPIRED (public kinds collapse both to HANDLE_INVALID).
484
+ */
485
+ async function load(kind, operation, raw, report) {
486
+ const outcome = normalizeHandleInternal(kind, raw);
487
+ if (!outcome.ok)
488
+ throw normalizationError(kind, outcome);
489
+ report(outcome.normalization, outcome.words.length);
490
+ const namespace = resolveNamespace(kind, operation);
491
+ const stored = await storage(kind, () => namespace.get(outcome.canonical));
492
+ if (!stored)
493
+ throw notFoundError(kind);
494
+ const value = stored.value;
495
+ if (!isStoredHandleRecord(value) ||
496
+ value.kind !== kind.name ||
497
+ value.type !== kind.type ||
498
+ value.status === "discarded") {
499
+ // Corrupt or foreign record under our key (or a discard tombstone that has
500
+ // not been reclaimed yet): unusable, never disclose contents.
501
+ if (isStoredHandleRecord(value) && value.status === "discarded")
502
+ throw notFoundError(kind);
503
+ throw kind.access === "public"
504
+ ? notFoundError(kind)
505
+ : normalizationError(kind, {
506
+ ok: false,
507
+ reason: "invalid",
508
+ detail: "record unreadable",
509
+ });
510
+ }
511
+ // A live `committing` claim is still doing work under its lease even if the
512
+ // draft's hard expiry passed meanwhile; callers must see BUSY, not EXPIRED,
513
+ // so they do not start a second flow while the first may still succeed.
514
+ if (value.status !== "committing") {
515
+ const now = nowMs();
516
+ if (now >= value.expires_at_ms || now >= value.max_expires_at_ms) {
517
+ throw expiredError(kind, expiredDeadline(kind, value, now));
518
+ }
519
+ }
520
+ return {
521
+ canonical: outcome.canonical,
522
+ normalization: outcome.normalization,
523
+ words: outcome.words,
524
+ namespace,
525
+ stored: { ...stored, value },
526
+ record: value,
527
+ };
528
+ }
529
+ async function create(kind, data) {
530
+ return await instrumented(kind, "create", async (report) => {
531
+ const validated = validateData(kind, "create", data);
532
+ const namespace = resolveNamespace(kind, "create");
533
+ const now = nowMs();
534
+ const lifetimeMs = isDraftKind(kind) ? kind.maxTtlMs : kind.ttlMs;
535
+ const record = {
536
+ v: RECORD_VERSION,
537
+ kind: kind.name,
538
+ type: kind.type,
539
+ status: "active",
540
+ data: validated,
541
+ created_at_ms: now,
542
+ expires_at_ms: now + (isDraftKind(kind) ? Math.min(kind.idleTtlMs, kind.maxTtlMs) : lifetimeMs),
543
+ max_expires_at_ms: now + lifetimeMs,
544
+ };
545
+ const writeTtlMs = storageTtlMs(kind, record, now);
546
+ assertSize(kind, record);
547
+ report([], kind.wordCount);
548
+ // CAS-if-absent (expected version 0). A collision with a live handle of
549
+ // the same scope simply retries with fresh words; five misses in a
550
+ // 1.68M (bound) or 2.8T+ (public) key space means storage is unhealthy.
551
+ for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt += 1) {
552
+ const canonical = `${kind.name}_${generateWords(kind.wordCount).join("-")}`;
553
+ const result = await storage(kind, () => namespace.compareAndSet(canonical, 0, record, { ttl: msDuration(writeTtlMs) }));
554
+ if (result.ok)
555
+ return { value: canonical, outcome: "success" };
556
+ }
557
+ throw storageUnavailableError(kind, `could not allocate a unique handle after ${CREATE_ATTEMPTS} attempts`);
558
+ });
559
+ }
560
+ async function read(kind, handle) {
561
+ return await instrumented(kind, "read", async (report) => {
562
+ const loaded = await load(kind, "read", handle, report);
563
+ const now = nowMs();
564
+ if (isDraftKind(kind) && loaded.record.status === "active") {
565
+ // Sliding TTL: rewrite the record with a fresh logical expiry. Losing the
566
+ // CAS means a concurrent update/commit already refreshed it.
567
+ const touched = {
568
+ ...loaded.record,
569
+ expires_at_ms: draftExpiresAtMs(kind, loaded.record, now),
570
+ };
571
+ await storage(kind, () => touchDraft(loaded.namespace, loaded.canonical, loaded.stored, touched, storageTtlMs(kind, touched, now)));
572
+ return { value: toHandleRecord(kind, loaded.canonical, touched), outcome: "success" };
573
+ }
574
+ return { value: toHandleRecord(kind, loaded.canonical, loaded.record), outcome: "success" };
575
+ });
576
+ }
577
+ async function touchDraft(namespace, canonical, stored, touched, ttlMs) {
578
+ await namespace.compareAndSet(canonical, stored.version, touched, {
579
+ ttl: msDuration(ttlMs),
580
+ });
581
+ }
582
+ async function update(kind, handle, updater) {
583
+ return await instrumented(kind, "update", async (report) => {
584
+ for (let attempt = 0; attempt < UPDATE_ATTEMPTS; attempt += 1) {
585
+ const loaded = await load(kind, "update", handle, report);
586
+ if (loaded.record.status === "committed")
587
+ throw committedError(kind);
588
+ if (loaded.record.status === "committing")
589
+ throw busyError(kind);
590
+ const nextData = validateData(kind, "update", await updater(detach(loaded.record.data)));
591
+ const now = nowMs();
592
+ const next = {
593
+ ...loaded.record,
594
+ data: nextData,
595
+ expires_at_ms: draftExpiresAtMs(kind, loaded.record, now),
596
+ };
597
+ assertSize(kind, next);
598
+ const ttlMs = storageTtlMs(kind, next, now);
599
+ const result = await storage(kind, () => loaded.namespace.compareAndSet(loaded.canonical, loaded.stored.version, next, {
600
+ ttl: msDuration(ttlMs),
601
+ }));
602
+ if (result.ok) {
603
+ return {
604
+ value: toHandleRecord(kind, loaded.canonical, next),
605
+ outcome: "success",
606
+ };
607
+ }
608
+ }
609
+ throw busyError(kind);
610
+ });
611
+ }
612
+ async function commit(kind, handle, work) {
613
+ return await instrumented(kind, "commit", async (report) => {
614
+ const loaded = await load(kind, "commit", handle, report);
615
+ if (loaded.record.status === "committed") {
616
+ return {
617
+ value: {
618
+ status: "replayed",
619
+ handle: loaded.canonical,
620
+ result: detach(loaded.record.result),
621
+ },
622
+ outcome: "replayed",
623
+ };
624
+ }
625
+ if (loaded.record.status === "committing")
626
+ throw busyError(kind);
627
+ // active → committing. The claim holds a lease that is independent of the
628
+ // draft's remaining idle ttl: upstream work started near expiry must still
629
+ // be able to persist its result, otherwise a successful mutation would be
630
+ // lost and the retry would see NOT_FOUND instead of the promised replay.
631
+ // A crashed committer leaves the record BUSY for at most the lease.
632
+ const claimId = randomUUID();
633
+ const committing = {
634
+ ...loaded.record,
635
+ status: "committing",
636
+ claim_id: claimId,
637
+ };
638
+ const remainingMs = storageTtlMs(kind, committing, nowMs());
639
+ let claim;
640
+ try {
641
+ claim = await storage(kind, () => loaded.namespace.compareAndSet(loaded.canonical, loaded.stored.version, committing, {
642
+ ttl: msDuration(Math.max(remainingMs, commitLeaseMs)),
643
+ }));
644
+ }
645
+ catch (error) {
646
+ // Ambiguous write: the claim may have landed although the response was
647
+ // lost. Re-read and recognise our own claim id; otherwise the draft is
648
+ // untouched (still active) and the storage error stands.
649
+ const current = await storage(kind, () => loaded.namespace.get(loaded.canonical));
650
+ if (current &&
651
+ isStoredHandleRecord(current.value) &&
652
+ current.value.status === "committing" &&
653
+ current.value.claim_id === claimId) {
654
+ claim = { ok: true, value: current };
655
+ }
656
+ else {
657
+ throw error;
658
+ }
659
+ }
660
+ if (!claim.ok) {
661
+ // Lost the race: another caller moved the record. Classify by what they did.
662
+ const current = claim.current?.value;
663
+ if (isStoredHandleRecord(current) && current.status === "committed") {
664
+ return {
665
+ value: {
666
+ status: "replayed",
667
+ handle: loaded.canonical,
668
+ result: detach(current.result),
669
+ },
670
+ outcome: "replayed",
671
+ };
672
+ }
673
+ throw busyError(kind);
674
+ }
675
+ let version = claim.value.version;
676
+ // Renew the claim while upstream work is running so its storage lifetime
677
+ // can never end underneath a slow callback (which would lose the result
678
+ // and turn the retry into NOT_FOUND instead of a replay). Best effort:
679
+ // a lost renewal CAS means another actor moved the record, and the
680
+ // finalize step below classifies that.
681
+ let renewing = true;
682
+ let wakeRenewal;
683
+ const stopRenewal = () => {
684
+ renewing = false;
685
+ wakeRenewal?.();
686
+ };
687
+ const renew = async () => {
688
+ while (renewing) {
689
+ await new Promise((resolve) => {
690
+ const timer = setTimeout(resolve, Math.max(1, Math.floor(commitLeaseMs / 2)));
691
+ timer.unref?.();
692
+ wakeRenewal = () => {
693
+ clearTimeout(timer);
694
+ resolve();
695
+ };
696
+ });
697
+ if (!renewing)
698
+ return;
699
+ try {
700
+ const renewed = await loaded.namespace.compareAndSet(loaded.canonical, version, committing, { ttl: msDuration(commitLeaseMs) });
701
+ if (renewed.ok) {
702
+ version = renewed.value.version;
703
+ continue;
704
+ }
705
+ // Lost the CAS. Only a `committing` record can still be ours (another
706
+ // actor cannot claim a committing draft); adopt its version and keep
707
+ // renewing, otherwise the record moved on and finalize classifies it.
708
+ const current = renewed.current;
709
+ if (current &&
710
+ isStoredHandleRecord(current.value) &&
711
+ current.value.status === "committing" &&
712
+ current.value.claim_id === claimId) {
713
+ version = current.version;
714
+ continue;
715
+ }
716
+ return;
717
+ }
718
+ catch {
719
+ // Ambiguous write (e.g. a Redis timeout after the SET landed): the
720
+ // version we hold may be stale. Re-read and adopt the live version so
721
+ // the next renewal does not fail its CAS and let the lease lapse.
722
+ try {
723
+ const current = await loaded.namespace.get(loaded.canonical);
724
+ if (current &&
725
+ isStoredHandleRecord(current.value) &&
726
+ current.value.status === "committing" &&
727
+ current.value.claim_id === claimId) {
728
+ version = current.version;
729
+ }
730
+ }
731
+ catch {
732
+ // Still unreachable; try again after the next interval.
733
+ }
734
+ }
735
+ }
736
+ };
737
+ const renewal = renew();
738
+ let result;
739
+ try {
740
+ result = await work(detach(loaded.record.data));
741
+ }
742
+ catch (error) {
743
+ stopRenewal();
744
+ await renewal;
745
+ // Restore committing → active so the caller can retry; best effort.
746
+ try {
747
+ // Refresh the idle deadline (bounded by max) so a confirm that failed
748
+ // after a long upstream call stays retryable instead of reading as
749
+ // expired the moment it is restored.
750
+ const restoreNow = nowMs();
751
+ const restored = {
752
+ ...loaded.record,
753
+ status: "active",
754
+ expires_at_ms: draftExpiresAtMs(kind, loaded.record, restoreNow),
755
+ };
756
+ const ttlMs = storageTtlMs(kind, restored, restoreNow);
757
+ await loaded.namespace.compareAndSet(loaded.canonical, version, restored, {
758
+ ttl: msDuration(ttlMs),
759
+ });
760
+ }
761
+ catch {
762
+ // The record stays `committing` until its ttl lapses; the work error wins.
763
+ }
764
+ throw error;
765
+ }
766
+ stopRenewal();
767
+ await renewal;
768
+ const now = nowMs();
769
+ // Results are persisted as JSON too: reject Date/Map/Set/class instances so
770
+ // the initial return and every replay carry the same value, and detach so
771
+ // the caller's post-processing cannot rewrite the stored replay.
772
+ const unsafeResultPath = findNonJsonSafePath(result, "result");
773
+ if (unsafeResultPath) {
774
+ throw invalidDataError(kind, "commit", new Error(`${unsafeResultPath} is not JSON-safe (Date, Map, Set, class instances, NaN, bigint and functions cannot be stored)`));
775
+ }
776
+ const committed = {
777
+ ...loaded.record,
778
+ status: "committed",
779
+ claim_id: undefined,
780
+ result: detach(result),
781
+ committed_at_ms: now,
782
+ expires_at_ms: now + kind.resultTtlMs,
783
+ max_expires_at_ms: now + kind.resultTtlMs,
784
+ };
785
+ // If the result does not fit we deliberately leave the record `committing`
786
+ // (BUSY until ttl) rather than `active`: the work already ran upstream and
787
+ // re-running it is the worse failure. This is a provider bug (internal_error).
788
+ assertSize(kind, committed);
789
+ // The work already ran upstream: persisting its result is the one step
790
+ // that must not give up on a transient storage error, so storage
791
+ // failures are retried (with a short pause) up to the attempt budget and
792
+ // an already-committed record is reconciled as a replay.
793
+ let lastStorageError;
794
+ for (let attempt = 0; attempt < COMMIT_FINALIZE_ATTEMPTS; attempt += 1) {
795
+ let finalize;
796
+ try {
797
+ finalize = await storage(kind, () => loaded.namespace.compareAndSet(loaded.canonical, version, committed, {
798
+ ttl: msDuration(storageTtlMs(kind, committed, now)),
799
+ }));
800
+ }
801
+ catch (error) {
802
+ lastStorageError = error;
803
+ await new Promise((resolve) => {
804
+ const timer = setTimeout(resolve, COMMIT_FINALIZE_RETRY_DELAY_MS * (attempt + 1));
805
+ timer.unref?.();
806
+ });
807
+ continue;
808
+ }
809
+ if (finalize.ok) {
810
+ return {
811
+ value: { status: "committed", handle: loaded.canonical, result: detach(result) },
812
+ outcome: "success",
813
+ };
814
+ }
815
+ const current = finalize.current;
816
+ if (!current || !isStoredHandleRecord(current.value))
817
+ break;
818
+ if (current.value.status === "committed") {
819
+ return {
820
+ value: {
821
+ status: "replayed",
822
+ handle: loaded.canonical,
823
+ result: detach(current.value.result),
824
+ },
825
+ outcome: "replayed",
826
+ };
827
+ }
828
+ if (current.value.status !== "committing" || current.value.claim_id !== claimId)
829
+ break;
830
+ version = current.version;
831
+ }
832
+ if (lastStorageError !== undefined)
833
+ throw lastStorageError;
834
+ throw storageUnavailableError(kind, "could not persist the commit result after the work completed");
835
+ });
836
+ }
837
+ async function discard(kind, handle) {
838
+ return await instrumented(kind, "discard", async (report) => {
839
+ const outcome = normalizeHandleInternal(kind, handle);
840
+ if (!outcome.ok)
841
+ throw normalizationError(kind, outcome);
842
+ report(outcome.normalization, outcome.words.length);
843
+ const namespace = resolveNamespace(kind, "discard");
844
+ // Discard participates in the draft state machine: a record that another
845
+ // caller has claimed for commit must not be deleted underneath it (the
846
+ // upstream work may succeed and needs to persist its replayable result).
847
+ // Deletion is a version-checked tombstone write; a concurrent refresh or
848
+ // update that wins the CAS is retried against the new version, a commit
849
+ // claim reports BUSY. The tombstone ttl is 1ms so it fits every namespace
850
+ // limit; reads treat it as absent and the slot is reclaimed right after.
851
+ for (let attempt = 0; attempt < DISCARD_ATTEMPTS; attempt += 1) {
852
+ const stored = await storage(kind, () => namespace.get(outcome.canonical));
853
+ if (!stored)
854
+ return { value: undefined, outcome: "success" };
855
+ const record = stored.value;
856
+ if (isStoredHandleRecord(record) && record.status === "committing")
857
+ throw busyError(kind);
858
+ const tombstone = isStoredHandleRecord(record)
859
+ ? { ...record, status: "discarded" }
860
+ : record;
861
+ const result = await storage(kind, () => namespace.compareAndSet(outcome.canonical, stored.version, tombstone, { ttl: "1ms" }));
862
+ if (result.ok) {
863
+ // The 1ms tombstone expires on its own (Redis PXAT / memory prune) and
864
+ // reads treat it as absent. No follow-up delete: the state API has no
865
+ // atomic conditional delete, and any read-then-delete could remove a
866
+ // record re-allocated under the same key after the tombstone expired.
867
+ return { value: undefined, outcome: "success" };
868
+ }
869
+ const current = result.current?.value;
870
+ if (isStoredHandleRecord(current) && current.status === "committing") {
871
+ throw busyError(kind);
872
+ }
873
+ if (!result.current)
874
+ return { value: undefined, outcome: "success" };
875
+ }
876
+ throw busyError(kind);
877
+ });
878
+ }
879
+ return { create, read, update, commit, discard };
880
+ }
881
+ /**
882
+ * Handle context for unit tests: memory state, a fixed connection, and an
883
+ * optional fake clock. Share one `state` between two contexts with different
884
+ * `request.connectionId`s to exercise bound isolation.
885
+ */
886
+ export function createTestHandleContext(options = {}) {
887
+ return createHandleContext({
888
+ providerId: options.providerId ?? "test-provider",
889
+ state: options.state ??
890
+ createMemoryProviderRuntimeState(options.nowMs ? { now: options.nowMs } : undefined),
891
+ request: options.request ?? { headers: {}, connectionId: "test-connection" },
892
+ nowMs: options.nowMs,
893
+ onTelemetry: options.onTelemetry,
894
+ });
895
+ }