@ganju/utils 0.0.14 → 0.0.16

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 (111) hide show
  1. package/dist/channelCustomer.d.ts +99 -0
  2. package/dist/channelCustomer.d.ts.map +1 -0
  3. package/dist/channelCustomer.js +533 -0
  4. package/dist/channelDebounce.d.ts +7 -0
  5. package/dist/channelDebounce.d.ts.map +1 -1
  6. package/dist/chunkOverlap.d.ts +2 -0
  7. package/dist/chunkOverlap.d.ts.map +1 -0
  8. package/dist/chunkOverlap.js +9 -0
  9. package/dist/chunking.d.ts.map +1 -1
  10. package/dist/chunking.js +24 -4
  11. package/dist/citations.d.ts +28 -0
  12. package/dist/citations.d.ts.map +1 -0
  13. package/dist/citations.js +111 -0
  14. package/dist/constants.d.ts +49 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +202 -17
  17. package/dist/data.d.ts +9 -0
  18. package/dist/data.d.ts.map +1 -0
  19. package/dist/data.js +78 -0
  20. package/dist/dataApi.d.ts +511 -0
  21. package/dist/dataApi.d.ts.map +1 -0
  22. package/dist/dataApi.js +355 -0
  23. package/dist/dataCompiler.d.ts +68 -0
  24. package/dist/dataCompiler.d.ts.map +1 -0
  25. package/dist/dataCompiler.js +726 -0
  26. package/dist/dataDdl.d.ts +29 -0
  27. package/dist/dataDdl.d.ts.map +1 -0
  28. package/dist/dataDdl.js +354 -0
  29. package/dist/dataEngine.d.ts +143 -0
  30. package/dist/dataEngine.d.ts.map +1 -0
  31. package/dist/dataEngine.js +2022 -0
  32. package/dist/dataIdentifiers.d.ts +8 -0
  33. package/dist/dataIdentifiers.d.ts.map +1 -0
  34. package/dist/dataIdentifiers.js +55 -0
  35. package/dist/dataImport.d.ts +77 -0
  36. package/dist/dataImport.d.ts.map +1 -0
  37. package/dist/dataImport.js +450 -0
  38. package/dist/dataRpc.d.ts +131 -0
  39. package/dist/dataRpc.d.ts.map +1 -0
  40. package/dist/dataRpc.js +2 -0
  41. package/dist/dataTypes.d.ts +157 -0
  42. package/dist/dataTypes.d.ts.map +1 -0
  43. package/dist/dataTypes.js +160 -0
  44. package/dist/index.d.ts +570 -36
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +72 -0
  47. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  48. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  49. package/dist/jsonSchemaToZodShape.js +21 -9
  50. package/dist/oauth.d.ts +14 -0
  51. package/dist/oauth.d.ts.map +1 -1
  52. package/dist/oauth.js +42 -2
  53. package/dist/pagination.d.ts +47 -0
  54. package/dist/pagination.d.ts.map +1 -0
  55. package/dist/pagination.js +160 -0
  56. package/dist/processQueueBatch.d.ts +6 -1
  57. package/dist/processQueueBatch.d.ts.map +1 -1
  58. package/dist/processQueueBatch.js +23 -10
  59. package/dist/queueFailure.d.ts +14 -0
  60. package/dist/queueFailure.d.ts.map +1 -0
  61. package/dist/queueFailure.js +43 -0
  62. package/dist/resourceRerank.d.ts +3 -2
  63. package/dist/resourceRerank.d.ts.map +1 -1
  64. package/dist/resourceRerank.js +8 -3
  65. package/dist/schema.d.ts +374 -29
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +290 -6
  68. package/dist/ssrf.d.ts +1 -0
  69. package/dist/ssrf.d.ts.map +1 -1
  70. package/dist/ssrf.js +48 -12
  71. package/dist/toolConfirmation.d.ts +48 -0
  72. package/dist/toolConfirmation.d.ts.map +1 -1
  73. package/dist/toolConfirmation.js +75 -1
  74. package/dist/toolCount.d.ts +13 -0
  75. package/dist/toolCount.d.ts.map +1 -0
  76. package/dist/toolCount.js +45 -0
  77. package/dist/toolLint.d.ts +1 -1
  78. package/dist/toolLint.d.ts.map +1 -1
  79. package/dist/toolLint.js +20 -0
  80. package/dist/untrustedContent.d.ts +135 -0
  81. package/dist/untrustedContent.d.ts.map +1 -0
  82. package/dist/untrustedContent.js +402 -0
  83. package/package.json +17 -1
  84. package/src/channelCustomer.ts +604 -0
  85. package/src/channelDebounce.ts +17 -0
  86. package/src/chunkOverlap.ts +6 -0
  87. package/src/chunking.ts +25 -4
  88. package/src/citations.ts +127 -0
  89. package/src/constants.ts +219 -17
  90. package/src/data.ts +74 -0
  91. package/src/dataApi.ts +428 -0
  92. package/src/dataCompiler.ts +948 -0
  93. package/src/dataDdl.ts +419 -0
  94. package/src/dataEngine.ts +2753 -0
  95. package/src/dataIdentifiers.ts +63 -0
  96. package/src/dataImport.ts +554 -0
  97. package/src/dataRpc.ts +121 -0
  98. package/src/dataTypes.ts +312 -0
  99. package/src/index.ts +156 -2
  100. package/src/jsonSchemaToZodShape.ts +31 -13
  101. package/src/oauth.ts +42 -1
  102. package/src/pagination.ts +193 -0
  103. package/src/processQueueBatch.ts +42 -12
  104. package/src/queueFailure.ts +63 -0
  105. package/src/resourceRerank.ts +10 -3
  106. package/src/schema.ts +346 -4
  107. package/src/ssrf.ts +40 -11
  108. package/src/toolConfirmation.ts +123 -0
  109. package/src/toolCount.ts +53 -0
  110. package/src/toolLint.ts +27 -0
  111. package/src/untrustedContent.ts +476 -0
@@ -1,7 +1,7 @@
1
1
  import * as z from 'zod';
2
2
 
3
3
  export type JsonSchemaProperty = {
4
- type: 'string' | 'number' | 'boolean' | 'object' | 'array';
4
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
5
5
  description?: string;
6
6
  minimum?: number;
7
7
  maximum?: number;
@@ -10,6 +10,10 @@ export type JsonSchemaProperty = {
10
10
  pattern?: string;
11
11
  enum?: string[];
12
12
  items?: JsonSchemaProperty;
13
+ minItems?: number;
14
+ maxItems?: number;
15
+ properties?: Record<string, JsonSchemaProperty>;
16
+ required?: string[];
13
17
  };
14
18
 
15
19
  export type JsonSchema = {
@@ -35,8 +39,9 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
35
39
  }
36
40
  break;
37
41
  }
38
- case 'number': {
39
- let num = z.number();
42
+ case 'number':
43
+ case 'integer': {
44
+ let num = prop.type === 'integer' ? z.number().int() : z.number();
40
45
  if (prop.minimum !== undefined) num = num.min(prop.minimum);
41
46
  if (prop.maximum !== undefined) num = num.max(prop.maximum);
42
47
  field = num;
@@ -45,11 +50,21 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
45
50
  case 'boolean':
46
51
  field = z.boolean();
47
52
  break;
48
- case 'array':
49
- field = z.array(prop.items ? propertyToZod(prop.items) : z.any());
53
+ case 'array': {
54
+ let arr = z.array(prop.items ? propertyToZod(prop.items) : z.any());
55
+ if (prop.minItems !== undefined) arr = arr.min(prop.minItems);
56
+ if (prop.maxItems !== undefined) arr = arr.max(prop.maxItems);
57
+ field = arr;
50
58
  break;
59
+ }
51
60
  case 'object':
52
- field = z.record(z.string(), z.any());
61
+ // With declared properties, keep them: this shape is also what
62
+ // `tools/list` advertises, and a bare record there tells the model an
63
+ // item is any object at all, so it invents the field names. Loose,
64
+ // because a schema without `additionalProperties: false` allows extras.
65
+ field = prop.properties
66
+ ? z.looseObject(shapeOf(prop.properties, prop.required))
67
+ : z.record(z.string(), z.any());
53
68
  break;
54
69
  default:
55
70
  field = z.any();
@@ -62,12 +77,10 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
62
77
  return field;
63
78
  };
64
79
 
65
- export const jsonSchemaToZodShape = (
66
- schema: JsonSchema
67
- ): Record<string, z.ZodTypeAny> => {
68
- const properties = schema.properties ?? {};
69
- const required = schema.required ?? [];
70
-
80
+ function shapeOf(
81
+ properties: Record<string, JsonSchemaProperty> = {},
82
+ required: string[] = []
83
+ ): Record<string, z.ZodTypeAny> {
71
84
  const shape: Record<string, z.ZodTypeAny> = {};
72
85
 
73
86
  for (const [key, prop] of Object.entries(properties)) {
@@ -76,7 +89,12 @@ export const jsonSchemaToZodShape = (
76
89
  }
77
90
 
78
91
  return shape;
79
- };
92
+ }
93
+
94
+ export const jsonSchemaToZodShape = (
95
+ schema: JsonSchema
96
+ ): Record<string, z.ZodTypeAny> =>
97
+ shapeOf(schema.properties, schema.required);
80
98
 
81
99
  export interface SchemaViolation {
82
100
  // Dotted path to the offending field, empty for the value as a whole.
package/src/oauth.ts CHANGED
@@ -24,6 +24,21 @@ export class OAuthReauthRequiredError extends Error {
24
24
  }
25
25
  }
26
26
 
27
+ // The token endpoint answered with an error that isn't a reauth code. `status`
28
+ // tells a rejection (4xx) from an outage (5xx); a network failure never gets
29
+ // here, it throws fetch's own error.
30
+ export class OAuthRefreshError extends Error {
31
+ readonly status: number;
32
+ readonly code: string | undefined;
33
+
34
+ constructor(status: number, code: string | undefined, body: string) {
35
+ super(`oauth refresh failed (${status}): ${body}`);
36
+ this.name = 'OAuthRefreshError';
37
+ this.status = status;
38
+ this.code = code;
39
+ }
40
+ }
41
+
27
42
  const parseErrorCode = (body: string): string | undefined => {
28
43
  try {
29
44
  return (JSON.parse(body) as { error?: string })?.error;
@@ -52,7 +67,7 @@ export const refreshOAuthToken = async (
52
67
  if (code && constants.REAUTH_ERROR_CODES.includes(code)) {
53
68
  throw new OAuthReauthRequiredError(code);
54
69
  }
55
- throw new Error(`oauth refresh failed (${response.status}): ${body}`);
70
+ throw new OAuthRefreshError(response.status, code, body);
56
71
  }
57
72
 
58
73
  const tokens = (await response.json()) as {
@@ -95,6 +110,32 @@ export const clearReauthMetadata = (
95
110
  return Object.keys(next).length > 0 ? next : null;
96
111
  };
97
112
 
113
+ /**
114
+ * Why a failed refresh should flag the credential for a reconnect, or null
115
+ * when it shouldn't. A reauth code flags it at once. Any other rejection
116
+ * (4xx) flags it only once the access token has been expired for
117
+ * CREDENTIAL_REFRESH_GIVE_UP_MS, so a one-off doesn't. Never flagged: an
118
+ * outage (5xx), a network error, rate limiting (429), and errors about our
119
+ * own client id / secret, which no reconnect can fix.
120
+ */
121
+ export const refreshFailureReauthReason = (
122
+ err: unknown,
123
+ expiresAt: Date | null,
124
+ now = Date.now()
125
+ ): string | null => {
126
+ if (err instanceof OAuthReauthRequiredError) return err.code;
127
+ if (!(err instanceof OAuthRefreshError)) return null;
128
+ if (err.status < 400 || err.status >= 500 || err.status === 429) return null;
129
+ if (err.code && constants.OAUTH_CLIENT_ERROR_CODES.includes(err.code)) {
130
+ return null;
131
+ }
132
+ if (!expiresAt) return null;
133
+ if (now - expiresAt.getTime() < constants.CREDENTIAL_REFRESH_GIVE_UP_MS) {
134
+ return null;
135
+ }
136
+ return err.code ? `refresh_rejected:${err.code}` : 'refresh_rejected';
137
+ };
138
+
98
139
  export const isCredentialNeedingReauth = (metadata: unknown): boolean => {
99
140
  if (!metadata || typeof metadata !== 'object') return false;
100
141
  return (metadata as Record<string, unknown>).needsReauth === true;
@@ -0,0 +1,193 @@
1
+ // Large tool results, in one shape: a list a tool owns is paged by a cursor the
2
+ // model passes back, and text that has to be cut says so, how much was left
3
+ // out, and where to continue when the result itself carried a cursor.
4
+ //
5
+ // Imports nothing, so node --test runs it directly.
6
+
7
+ // Keys a paged API commonly answers with. Read at the top level only: deeper
8
+ // than that, a key with one of these names is somebody's data.
9
+ const NEXT_CURSOR_KEYS = [
10
+ 'nextCursor',
11
+ 'next_cursor',
12
+ 'nextPageToken',
13
+ 'next_page_token',
14
+ 'nextPage',
15
+ 'next_page',
16
+ 'next'
17
+ ];
18
+
19
+ /**
20
+ * The cursor for the next page a JSON result names at its top level, if any.
21
+ * Cut text can lose it, so it is read from the parsed value before cutting.
22
+ */
23
+ export const findNextCursor = (value: unknown): string | undefined => {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
25
+ return undefined;
26
+ }
27
+ for (const key of NEXT_CURSOR_KEYS) {
28
+ const found = (value as Record<string, unknown>)[key];
29
+ if (typeof found === 'string' && found) return `${key}: ${found}`;
30
+ if (typeof found === 'number') return `${key}: ${found}`;
31
+ }
32
+ return undefined;
33
+ };
34
+
35
+ const kb = (bytes: number) => `${Math.max(1, Math.round(bytes / 1024))} KB`;
36
+
37
+ /**
38
+ * `text` within `maxBytes` of UTF-8, or its start with a note that says it was
39
+ * cut. A character split by the cut is dropped rather than shown broken.
40
+ * `next` names the result's own cursor, for "call again with it".
41
+ */
42
+ export const capText = (
43
+ text: string,
44
+ maxBytes: number,
45
+ next?: string
46
+ ): string => {
47
+ const bytes = new TextEncoder().encode(text);
48
+ if (bytes.byteLength <= maxBytes) return text;
49
+
50
+ const kept = new TextDecoder()
51
+ .decode(bytes.slice(0, maxBytes))
52
+ .replace(/�+$/, '');
53
+ const continueWith = next
54
+ ? ` The result names its next page (${next}): call again with it to continue.`
55
+ : ' To see the rest, call again asking for less — narrower filters, a smaller page or fewer fields.';
56
+ return `${kept}\n\n[Truncated: showing the first ${kb(maxBytes)} of ${kb(
57
+ bytes.byteLength
58
+ )}.${continueWith}]`;
59
+ };
60
+
61
+ // Opaque to the model, so it is passed back as given rather than edited.
62
+ const encodeCursor = (key: string): string =>
63
+ btoa(String.fromCharCode(...new TextEncoder().encode(key)))
64
+ .replace(/\+/g, '-')
65
+ .replace(/\//g, '_')
66
+ .replace(/=+$/, '');
67
+
68
+ const decodeCursor = (cursor: string): string | null => {
69
+ try {
70
+ const base64 = cursor.replace(/-/g, '+').replace(/_/g, '/');
71
+ const binary = atob(base64);
72
+ return new TextDecoder('utf-8', { fatal: true }).decode(
73
+ Uint8Array.from(binary, char => char.charCodeAt(0))
74
+ );
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ // Between a cursor's key and the count of items with that key already served.
81
+ // A key is a uri or a name, never a NUL.
82
+ const CURSOR_SEEN_SEPARATOR = '\u0000';
83
+
84
+ // A cursor with no count (one issued before the count was added) resumes
85
+ // after every item with its key.
86
+ const splitCursorKey = (decoded: string): { key: string; seen: number } => {
87
+ const at = decoded.lastIndexOf(CURSOR_SEEN_SEPARATOR);
88
+ if (at === -1) return { key: decoded, seen: Infinity };
89
+ const count = decoded.slice(at + 1);
90
+ if (!/^\d+$/.test(count)) throw new InvalidCursorError();
91
+ return { key: decoded.slice(0, at), seen: Number(count) };
92
+ };
93
+
94
+ export class InvalidCursorError extends Error {
95
+ constructor() {
96
+ super(
97
+ 'That cursor is not valid. Call again without a cursor to start from the first page.'
98
+ );
99
+ this.name = 'InvalidCursorError';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * What `list-resources` answers, wherever it runs (the MCP worker, and the
105
+ * channel runner, which lists proxied resources too): one page by uri, how many
106
+ * there are in all, and the cursor for the next page when there is one.
107
+ */
108
+ export const listResourcesPage = (
109
+ resources: Array<{
110
+ uri: string;
111
+ title?: string | null;
112
+ description?: string | null;
113
+ mimeType?: string | null;
114
+ }>,
115
+ args: { cursor?: unknown; limit?: unknown },
116
+ limits: { defaultLimit: number; maxLimit: number }
117
+ ): string => {
118
+ const { page, total, nextCursor } = paginate(resources, r => r.uri, {
119
+ cursor: typeof args.cursor === 'string' ? args.cursor : undefined,
120
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
121
+ ...limits
122
+ });
123
+ return JSON.stringify({
124
+ resources: page.map(r => ({
125
+ uri: r.uri,
126
+ title: r.title || undefined,
127
+ description: r.description || undefined,
128
+ mimeType: r.mimeType || undefined
129
+ })),
130
+ total,
131
+ ...(nextCursor ? { nextCursor } : {})
132
+ });
133
+ };
134
+
135
+ /**
136
+ * One page of `items`, ordered by `keyOf`, starting after the item the cursor
137
+ * names. Keyed rather than counted, so an item added or removed between calls
138
+ * doesn't shift the next page onto ones already seen.
139
+ */
140
+ export const paginate = <T>(
141
+ items: T[],
142
+ keyOf: (item: T) => string,
143
+ options: {
144
+ cursor?: string;
145
+ limit?: number;
146
+ defaultLimit: number;
147
+ maxLimit: number;
148
+ }
149
+ ): { page: T[]; nextCursor?: string; total: number } => {
150
+ const limit = Math.min(
151
+ Math.max(Math.floor(Number(options.limit) || options.defaultLimit), 1),
152
+ options.maxLimit
153
+ );
154
+ const sorted = [...items].sort((a, b) => {
155
+ const left = keyOf(a);
156
+ const right = keyOf(b);
157
+ return left < right ? -1 : left > right ? 1 : 0;
158
+ });
159
+
160
+ let start = 0;
161
+ if (options.cursor) {
162
+ const decoded = decodeCursor(options.cursor);
163
+ if (decoded === null) throw new InvalidCursorError();
164
+ const { key: after, seen } = splitCursorKey(decoded);
165
+ start = sorted.findIndex(item => keyOf(item) >= after);
166
+ if (start === -1) start = sorted.length;
167
+ // Items that share the cursor's key: skip the ones already served, in the
168
+ // order the sort (which is stable) keeps them.
169
+ for (
170
+ let skipped = 0;
171
+ start < sorted.length && keyOf(sorted[start]) === after && skipped < seen;
172
+ skipped++
173
+ ) {
174
+ start++;
175
+ }
176
+ }
177
+
178
+ const page = sorted.slice(start, start + limit);
179
+ const more = start + limit < sorted.length;
180
+ if (!more || page.length === 0) return { page, total: sorted.length };
181
+
182
+ // The last key served, and how many items with that key have been served
183
+ // so far, so a run of equal keys split across pages resumes inside the run.
184
+ const end = start + page.length;
185
+ const lastKey = keyOf(page[page.length - 1]);
186
+ let seen = 0;
187
+ for (let i = end - 1; i >= 0 && keyOf(sorted[i]) === lastKey; i--) seen++;
188
+ return {
189
+ page,
190
+ total: sorted.length,
191
+ nextCursor: encodeCursor(`${lastKey}${CURSOR_SEEN_SEPARATOR}${seen}`)
192
+ };
193
+ };
@@ -1,8 +1,12 @@
1
1
  import { isRateLimitError, isTransientInfraError } from './retry';
2
2
  import { constants } from './constants';
3
+ import { isPermanentJobError, planQueueFailure } from './queueFailure';
4
+ import type { QueueFailureKind } from './queueFailure';
3
5
 
4
6
  export interface QueueMessageLike<T> {
5
7
  body: T;
8
+ // Deliveries so far, including this one (Cloudflare Queues' `attempts`).
9
+ attempts?: number;
6
10
  ack: () => void;
7
11
  retry: (opts?: { delaySeconds?: number }) => void;
8
12
  }
@@ -12,32 +16,58 @@ export interface QueueBatchLike<T> {
12
16
  messages: readonly QueueMessageLike<T>[];
13
17
  }
14
18
 
19
+ export interface QueueFailureContext {
20
+ // No delivery follows this one — the moment to mark the work failed. Before
21
+ // it, a retry may still succeed, and a status flipped to failed in between
22
+ // would show a resource as broken while it is being fixed.
23
+ final: boolean;
24
+ }
25
+
15
26
  export interface ProcessQueueBatchHandlers<T> {
16
27
  process: (body: T) => Promise<void>;
17
- onError: (error: unknown, body: T, queueName: string) => Promise<void>;
28
+ onError: (
29
+ error: unknown,
30
+ body: T,
31
+ queueName: string,
32
+ context: QueueFailureContext
33
+ ) => Promise<void>;
34
+ // The consumer's configured `max_retries`.
35
+ maxRetries?: number;
18
36
  }
19
37
 
38
+ const failureKind = (error: unknown): QueueFailureKind => {
39
+ if (isPermanentJobError(error)) return 'permanent';
40
+ if (isTransientInfraError(error)) return 'transient';
41
+ if (isRateLimitError(error)) return 'rateLimit';
42
+ return 'other';
43
+ };
44
+
20
45
  export const processQueueBatch = async <T>(
21
46
  batch: QueueBatchLike<T>,
22
47
  handlers: ProcessQueueBatchHandlers<T>
23
48
  ): Promise<void> => {
49
+ const maxRetries = handlers.maxRetries ?? constants.QUEUE_MAX_RETRIES;
24
50
  for (const message of batch.messages) {
25
51
  try {
26
52
  await handlers.process(message.body);
27
53
  message.ack();
28
54
  } catch (error) {
29
- if (isTransientInfraError(error)) {
30
- // Transient Cloudflare platform hiccup (e.g. a Durable Object /
31
- // Container reset). The job itself is fine, so re-run it after a short
32
- // backoff without reporting an error or marking the work failed.
33
- message.retry({ delaySeconds: constants.TRANSIENT_BACKOFF_SECONDS });
34
- continue;
35
- }
36
- await handlers.onError(error, message.body, batch.queue);
37
- if (isRateLimitError(error)) {
38
- message.retry({
39
- delaySeconds: constants.RATE_LIMIT_BACKOFF_SECONDS
55
+ const plan = planQueueFailure(
56
+ failureKind(error),
57
+ message.attempts,
58
+ maxRetries
59
+ );
60
+ if (plan.report) {
61
+ await handlers.onError(error, message.body, batch.queue, {
62
+ final: plan.final
40
63
  });
64
+ }
65
+ if (plan.action === 'ack') {
66
+ message.ack();
67
+ } else if (plan.backoff === 'transient') {
68
+ message.retry({ delaySeconds: constants.TRANSIENT_BACKOFF_SECONDS });
69
+ } else if (plan.backoff === 'rateLimit') {
70
+ message.retry({ delaySeconds: constants.RATE_LIMIT_BACKOFF_SECONDS });
41
71
  } else {
42
72
  message.retry();
43
73
  }
@@ -0,0 +1,63 @@
1
+ // How a failed queue job is handled: retried, given up on, or reported. Kept
2
+ // apart from processQueueBatch, which applies it, so the decision is testable
3
+ // on its own.
4
+
5
+ // A failure that no retry can fix — a credential that needs the user to
6
+ // reconnect, a resource that no longer exists. The job is reported once and
7
+ // acknowledged rather than retried into the dead-letter queue. Recognised by
8
+ // its `permanent` flag rather than its class, so an error built by another
9
+ // copy of this module still counts.
10
+ export class PermanentJobError extends Error {
11
+ readonly permanent = true;
12
+
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = 'PermanentJobError';
16
+ }
17
+ }
18
+
19
+ export const isPermanentJobError = (error: unknown): boolean =>
20
+ !!error &&
21
+ typeof error === 'object' &&
22
+ (error as { permanent?: unknown }).permanent === true;
23
+
24
+ export type QueueFailureKind =
25
+ | 'permanent'
26
+ | 'transient'
27
+ | 'rateLimit'
28
+ | 'other';
29
+
30
+ export interface QueueFailurePlan {
31
+ // Hand the failure to the job's onError, which reports it.
32
+ report: boolean;
33
+ // No delivery follows this one: the job's work may be marked failed.
34
+ final: boolean;
35
+ // What to tell the queue about this message.
36
+ action: 'ack' | 'retry';
37
+ // Which backoff a retry waits out; null for the queue's default.
38
+ backoff: 'transient' | 'rateLimit' | null;
39
+ }
40
+
41
+ // `attempts` is the queue's own count, 1 on the first delivery; a consumer
42
+ // with `maxRetries` retries gets maxRetries + 1 deliveries in all.
43
+ export const planQueueFailure = (
44
+ kind: QueueFailureKind,
45
+ attempts: number | undefined,
46
+ maxRetries: number
47
+ ): QueueFailurePlan => {
48
+ if (kind === 'permanent') {
49
+ return { report: true, final: true, action: 'ack', backoff: null };
50
+ }
51
+ const final = (attempts ?? 1) > maxRetries;
52
+ if (kind === 'transient') {
53
+ // A platform hiccup is retried quietly; only the last one is worth a
54
+ // report, since nothing will run the job after it.
55
+ return { report: final, final, action: 'retry', backoff: 'transient' };
56
+ }
57
+ return {
58
+ report: true,
59
+ final,
60
+ action: 'retry',
61
+ backoff: kind === 'rateLimit' ? 'rateLimit' : null
62
+ };
63
+ };
@@ -15,7 +15,8 @@ type RerankResponse = {
15
15
  };
16
16
 
17
17
  /**
18
- * The cross-encoder second stage for resource search, or undefined when the
18
+ * The cross-encoder second stage for resource search, or undefined when it is
19
+ * off (RESOURCE_SEARCH_RERANK_ENABLED, unless `enabled` overrides it) or the
19
20
  * Worker has no `AI` binding — local dev without Workers AI keeps plain fusion.
20
21
  *
21
22
  * Returns one score per document, in input order. A response that doesn't
@@ -23,10 +24,12 @@ type RerankResponse = {
23
24
  * ranking would silently drop the chunks it left out.
24
25
  */
25
26
  export const createResourceReranker = (
26
- source: EnvSource
27
+ source: EnvSource,
28
+ enabled: boolean = constants.RESOURCE_SEARCH_RERANK_ENABLED
27
29
  ):
28
30
  | ((query: string, documents: string[]) => Promise<number[] | null>)
29
31
  | undefined => {
32
+ if (!enabled) return undefined;
30
33
  const ai = (source.env as { AI?: RerankAiBinding } | undefined)?.AI;
31
34
  if (!ai) return undefined;
32
35
 
@@ -37,7 +40,11 @@ export const createResourceReranker = (
37
40
  top_k: documents.length
38
41
  })) as RerankResponse;
39
42
 
40
- const scores: Array<number | undefined> = new Array(documents.length);
43
+ // Filled, not just sized: `some` skips the holes of a bare new Array(n), so
44
+ // a document the response left out would pass the check below unscored.
45
+ const scores: Array<number | undefined> = new Array(documents.length).fill(
46
+ undefined
47
+ );
41
48
  for (const item of result?.response ?? []) {
42
49
  if (
43
50
  typeof item.id === 'number' &&