@visorcraft/mongreldb-kit 0.54.0 → 0.57.0

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/dist/remote.js CHANGED
@@ -1,10 +1,1126 @@
1
1
  import { RemoteDatabase as NativeRemoteDatabase } from '@visorcraft/mongreldb/native.js';
2
2
  import { tableFromIPC, tableFromJSON } from 'apache-arrow';
3
3
  import { randomBytes } from 'node:crypto';
4
- import { KitError, KitUnsupportedError, QueryCancelledError, QueryIdConflictError, QueryTimeoutError, TransactionAbortedError } from './errors.js';
4
+ import { CommitOutcomeError, CapabilityUnsupportedError, KitError, QueryCancelledError, QueryIdConflictError, QueryExecutionError, QueryOutcomeUnknownError, RemoteProtocolError, ResultLimitExceededError, SerializationError, QueryTimeoutError, TransactionAbortedError } from './errors.js';
5
5
  import { procedureJson } from './procedure.js';
6
6
  import { triggerJson } from './trigger.js';
7
7
  import { createVirtualTableSql, dropVirtualTableSql } from './external.js';
8
+ const SQL_RECOVERY_WINDOW_MS = 2_000;
9
+ const SQL_RECOVERY_REQUEST_TIMEOUT_MS = 250;
10
+ const SQL_RECOVERY_POLL_INTERVAL_MS = 25;
11
+ const MAX_U64 = 18446744073709551615n;
12
+ const MAX_CONTROL_JSON_RESPONSE_BYTES = 1024 * 1024;
13
+ const MAX_PAGE_JSON_RESPONSE_BYTES = 64 * 1024 * 1024;
14
+ const TERMINAL_SQL_STATES = new Set(['completed', 'failed', 'cancelled', 'pre_cancelled']);
15
+ function containsHttpHeaderControl(value) {
16
+ return /[\u0000-\u001f\u007f]/u.test(value);
17
+ }
18
+ function parseJsonStrict(text) {
19
+ let offset = 0;
20
+ const fail = (message) => {
21
+ throw new SyntaxError(`${message} at JSON offset ${offset}`);
22
+ };
23
+ const whitespace = () => {
24
+ while (offset < text.length
25
+ && [' ', '\t', '\r', '\n'].includes(text[offset]))
26
+ offset += 1;
27
+ };
28
+ const string = () => {
29
+ if (text[offset] !== '"')
30
+ fail('expected string');
31
+ const start = offset++;
32
+ while (offset < text.length) {
33
+ const char = text[offset++];
34
+ if (char === '"')
35
+ return JSON.parse(text.slice(start, offset));
36
+ if (char.charCodeAt(0) < 0x20)
37
+ fail('unescaped control character');
38
+ if (char !== '\\')
39
+ continue;
40
+ if (offset >= text.length)
41
+ fail('unterminated escape');
42
+ const escape = text[offset++];
43
+ if (escape === 'u') {
44
+ if (!/^[0-9a-fA-F]{4}$/.test(text.slice(offset, offset + 4))) {
45
+ fail('invalid unicode escape');
46
+ }
47
+ offset += 4;
48
+ }
49
+ else if (!'"\\/bfnrt'.includes(escape)) {
50
+ fail('invalid string escape');
51
+ }
52
+ }
53
+ return fail('unterminated string');
54
+ };
55
+ const value = () => {
56
+ whitespace();
57
+ const char = text[offset];
58
+ if (char === '"')
59
+ return string();
60
+ if (char === '{') {
61
+ offset += 1;
62
+ const object = Object.create(null);
63
+ const keys = new Set();
64
+ whitespace();
65
+ if (text[offset] === '}') {
66
+ offset += 1;
67
+ return object;
68
+ }
69
+ while (true) {
70
+ whitespace();
71
+ const key = string();
72
+ if (keys.has(key))
73
+ fail(`duplicate JSON object key ${JSON.stringify(key)}`);
74
+ keys.add(key);
75
+ whitespace();
76
+ if (text[offset++] !== ':')
77
+ fail('expected colon');
78
+ object[key] = value();
79
+ whitespace();
80
+ const separator = text[offset++];
81
+ if (separator === '}')
82
+ return object;
83
+ if (separator !== ',')
84
+ fail('expected comma or object end');
85
+ }
86
+ }
87
+ if (char === '[') {
88
+ offset += 1;
89
+ const array = [];
90
+ whitespace();
91
+ if (text[offset] === ']') {
92
+ offset += 1;
93
+ return array;
94
+ }
95
+ while (true) {
96
+ array.push(value());
97
+ whitespace();
98
+ const separator = text[offset++];
99
+ if (separator === ']')
100
+ return array;
101
+ if (separator !== ',')
102
+ fail('expected comma or array end');
103
+ }
104
+ }
105
+ for (const [literal, parsed] of [['true', true], ['false', false], ['null', null]]) {
106
+ if (text.startsWith(literal, offset)) {
107
+ offset += literal.length;
108
+ return parsed;
109
+ }
110
+ }
111
+ const number = text.slice(offset).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u)?.[0];
112
+ if (!number)
113
+ return fail('expected JSON value');
114
+ offset += number.length;
115
+ const parsed = Number(number);
116
+ if (!Number.isFinite(parsed))
117
+ return fail('non-finite JSON number');
118
+ return parsed;
119
+ };
120
+ const parsed = value();
121
+ whitespace();
122
+ if (offset !== text.length)
123
+ fail('trailing JSON data');
124
+ return parsed;
125
+ }
126
+ async function responseBytesBounded(response, limit) {
127
+ const declared = response.headers.get('content-length');
128
+ if (declared !== null) {
129
+ if (!/^\d+$/u.test(declared) || Number(declared) > limit) {
130
+ throw new SyntaxError(`HTTP response exceeded ${limit} bytes`);
131
+ }
132
+ }
133
+ if (!response.body)
134
+ return new Uint8Array();
135
+ const reader = response.body.getReader();
136
+ const chunks = [];
137
+ let length = 0;
138
+ try {
139
+ while (true) {
140
+ const { done, value } = await reader.read();
141
+ if (done)
142
+ break;
143
+ length += value.byteLength;
144
+ if (length > limit) {
145
+ await reader.cancel().catch(() => undefined);
146
+ throw new SyntaxError(`HTTP response exceeded ${limit} bytes`);
147
+ }
148
+ chunks.push(value);
149
+ }
150
+ }
151
+ finally {
152
+ reader.releaseLock();
153
+ }
154
+ const bytes = new Uint8Array(length);
155
+ let offset = 0;
156
+ for (const chunk of chunks) {
157
+ bytes.set(chunk, offset);
158
+ offset += chunk.byteLength;
159
+ }
160
+ return bytes;
161
+ }
162
+ async function responseTextBounded(response, limit) {
163
+ try {
164
+ return new TextDecoder('utf-8', { fatal: true }).decode(await responseBytesBounded(response, limit));
165
+ }
166
+ catch {
167
+ throw new SyntaxError('HTTP response was not valid UTF-8');
168
+ }
169
+ }
170
+ async function responseJsonStrict(response, limit = MAX_CONTROL_JSON_RESPONSE_BYTES) {
171
+ return parseJsonStrict(await responseTextBounded(response, limit));
172
+ }
173
+ function preExecutionCancellationError(queryId, outcome) {
174
+ const metadata = {
175
+ cancelOutcome: outcome,
176
+ cancellationReason: 'client_request',
177
+ retryable: false,
178
+ serverState: 'pre_cancelled'
179
+ };
180
+ if (outcome === 'not_found') {
181
+ return new KitError('SQL pre-registration cancellation was not found by the server', 'QUERY_NOT_FOUND');
182
+ }
183
+ if (outcome === 'too_late') {
184
+ return new KitError('SQL cancellation arrived after the commit fence', 'CANCEL_TOO_LATE');
185
+ }
186
+ if (outcome === 'already_finished') {
187
+ return new KitError('SQL query ID already belongs to a finished query', 'QUERY_ALREADY_FINISHED');
188
+ }
189
+ return new QueryCancelledError(queryId, 'SQL query cancelled before execution', { committed: false, committedStatements: 0, completedStatements: 0, statementIndex: 0 }, metadata);
190
+ }
191
+ function isNonNegativeInteger(value) {
192
+ return Number.isSafeInteger(value) && value >= 0;
193
+ }
194
+ function isOptionalNonNegativeInteger(value) {
195
+ return value === undefined || value === null || isNonNegativeInteger(value);
196
+ }
197
+ function hasOnlyKeys(value, allowed) {
198
+ return Object.keys(value).every((key) => allowed.includes(key));
199
+ }
200
+ function hasAllKeys(value, required) {
201
+ return required.every((key) => Object.prototype.hasOwnProperty.call(value, key));
202
+ }
203
+ function normalizeQueryId(value) {
204
+ if (typeof value !== 'string' || !/^[0-9a-f]{32}$/i.test(value)) {
205
+ throw new TypeError('queryId must be exactly 32 hexadecimal characters');
206
+ }
207
+ return value.toLowerCase();
208
+ }
209
+ function isOptionalEpoch(value, exactText) {
210
+ if (exactText !== undefined && exactText !== null
211
+ && (typeof exactText !== 'string' || !/^\d+$/.test(exactText)))
212
+ return false;
213
+ if (typeof exactText === 'string'
214
+ && (BigInt(exactText).toString() !== exactText || BigInt(exactText) > MAX_U64))
215
+ return false;
216
+ if (value === undefined || value === null)
217
+ return true;
218
+ if (!Number.isSafeInteger(value) || value < 0)
219
+ return false;
220
+ if (typeof exactText === 'string')
221
+ return BigInt(value) === BigInt(exactText);
222
+ return true;
223
+ }
224
+ function receiptEpoch(value, exactText) {
225
+ if (typeof exactText === 'string')
226
+ return BigInt(exactText);
227
+ return typeof value === 'number' ? BigInt(value) : undefined;
228
+ }
229
+ function isQueryNotFoundResponse(value, queryId) {
230
+ if (value === null || typeof value !== 'object')
231
+ return false;
232
+ const body = value;
233
+ const outcome = body.outcome;
234
+ const error = body.error;
235
+ const nullable = [
236
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
237
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
238
+ 'statement_index'
239
+ ];
240
+ return hasOnlyKeys(body, [
241
+ 'query_id', 'status', 'terminal_state', ...nullable, 'cancel_outcome',
242
+ 'cancellation_reason', 'retryable', 'server_state', 'outcome', 'error'
243
+ ]) && hasAllKeys(body, [
244
+ 'query_id', 'status', 'terminal_state', ...nullable, 'cancel_outcome',
245
+ 'cancellation_reason', 'retryable', 'server_state', 'outcome', 'error'
246
+ ])
247
+ && outcome !== null && typeof outcome === 'object'
248
+ && hasOnlyKeys(outcome, [...nullable, 'serialization'])
249
+ && hasAllKeys(outcome, [...nullable, 'serialization'])
250
+ && error !== null && typeof error === 'object'
251
+ && hasOnlyKeys(error, ['code', 'message', 'query_id', 'committed', 'retryable'])
252
+ && hasAllKeys(error, ['code', 'message', 'query_id', 'committed', 'retryable'])
253
+ && body.query_id === queryId && body.status === 'unknown'
254
+ && body.terminal_state === null && body.cancel_outcome === 'not_found'
255
+ && body.cancellation_reason === null && body.retryable === false
256
+ && body.server_state === 'not_found'
257
+ && nullable.every((field) => body[field] === null && outcome[field] === null)
258
+ && outcome.serialization === 'unknown'
259
+ && error.code === 'QUERY_NOT_FOUND'
260
+ && typeof error.message === 'string' && error.message.length > 0
261
+ && error.query_id === queryId && error.committed === null && error.retryable === false;
262
+ }
263
+ function isRemoteSqlWriteReceipt(value, queryId, expectedOriginalQueryId) {
264
+ if (value === null || typeof value !== 'object')
265
+ return false;
266
+ const body = value;
267
+ const outcome = body.outcome;
268
+ if (outcome === null || typeof outcome !== 'object')
269
+ return false;
270
+ const durable = outcome;
271
+ const terminal = body.terminal_error;
272
+ if (!hasOnlyKeys(body, [
273
+ 'query_id', 'original_query_id', 'status', 'terminal_state', 'server_state',
274
+ 'cancel_outcome', 'cancellation_reason', 'committed', 'committed_statements',
275
+ 'last_commit_epoch', 'last_commit_epoch_text', 'first_commit_statement_index',
276
+ 'last_commit_statement_index', 'completed_statements', 'statement_index', 'retryable',
277
+ 'idempotency_replayed', 'idempotency_persisted', 'idempotency_expires_at_ms',
278
+ 'outcome', 'terminal_error'
279
+ ]) || !hasOnlyKeys(durable, [
280
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
281
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
282
+ 'statement_index', 'serialization'
283
+ ]) || !hasAllKeys(durable, [
284
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
285
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
286
+ 'statement_index', 'serialization'
287
+ ]))
288
+ return false;
289
+ if (terminal !== undefined && terminal !== null
290
+ && (typeof terminal !== 'object'
291
+ || !hasOnlyKeys(terminal, ['code', 'category'])))
292
+ return false;
293
+ const statusCommitted = body.status === 'completed'
294
+ ? false
295
+ : ['committed', 'committed_with_error', 'partially_committed',
296
+ 'cancelled_after_commit', 'deadline_after_commit'].includes(body.status)
297
+ ? true
298
+ : undefined;
299
+ const shapeValid = body.query_id === queryId
300
+ && typeof body.original_query_id === 'string'
301
+ && /^[0-9a-f]{32}$/i.test(body.original_query_id)
302
+ && typeof body.status === 'string'
303
+ && body.status.trim().length > 0
304
+ && (body.terminal_state === undefined || body.terminal_state === null
305
+ || body.terminal_state === body.status)
306
+ && (body.server_state === undefined || body.server_state === null
307
+ || ['completed', 'failed', 'cancelled'].includes(body.server_state))
308
+ && (body.cancel_outcome === undefined || body.cancel_outcome === null
309
+ || body.cancel_outcome === 'already_finished')
310
+ && (body.cancellation_reason === undefined || body.cancellation_reason === null
311
+ || ['none', 'client_request', 'deadline', 'client_disconnected',
312
+ 'session_closed', 'server_shutdown'].includes(body.cancellation_reason))
313
+ && typeof body.committed === 'boolean'
314
+ && isNonNegativeInteger(body.committed_statements)
315
+ && body.committed === durable.committed
316
+ && body.committed_statements === durable.committed_statements
317
+ && typeof body.retryable === 'boolean'
318
+ && typeof body.idempotency_replayed === 'boolean'
319
+ && typeof body.idempotency_persisted === 'boolean'
320
+ && isNonNegativeInteger(body.idempotency_expires_at_ms)
321
+ && typeof durable.committed === 'boolean'
322
+ && isNonNegativeInteger(durable.committed_statements)
323
+ && ['not_started', 'in_progress', 'succeeded', 'failed', 'unknown']
324
+ .includes(durable.serialization)
325
+ && isOptionalEpoch(body.last_commit_epoch, body.last_commit_epoch_text)
326
+ && isOptionalNonNegativeInteger(body.first_commit_statement_index)
327
+ && isOptionalNonNegativeInteger(body.last_commit_statement_index)
328
+ && isNonNegativeInteger(body.completed_statements)
329
+ && isNonNegativeInteger(body.statement_index)
330
+ && isOptionalEpoch(durable.last_commit_epoch, durable.last_commit_epoch_text)
331
+ && isOptionalNonNegativeInteger(durable.first_commit_statement_index)
332
+ && isOptionalNonNegativeInteger(durable.last_commit_statement_index)
333
+ && (terminal === undefined || terminal === null
334
+ || typeof terminal === 'object'
335
+ && typeof terminal.code === 'string'
336
+ && terminal.code.trim().length > 0
337
+ && ['cancellation', 'deadline', 'result_limit', 'serialization', 'execution']
338
+ .includes(terminal.category));
339
+ if (!shapeValid)
340
+ return false;
341
+ if (statusCommitted === undefined || body.committed !== statusCommitted)
342
+ return false;
343
+ if (body.server_state != null) {
344
+ const expectedState = ['completed', 'committed'].includes(body.status)
345
+ ? 'completed'
346
+ : ['committed_with_error', 'partially_committed'].includes(body.status)
347
+ ? 'failed' : 'cancelled';
348
+ if (body.server_state !== expectedState)
349
+ return false;
350
+ }
351
+ if (body.cancellation_reason != null) {
352
+ const reasonMatches = body.status === 'cancelled_after_commit'
353
+ ? !['none', 'deadline'].includes(body.cancellation_reason)
354
+ : body.status === 'deadline_after_commit'
355
+ ? body.cancellation_reason === 'deadline'
356
+ : body.cancellation_reason === 'none';
357
+ if (!reasonMatches)
358
+ return false;
359
+ }
360
+ const terminalRecord = terminal;
361
+ if (terminalRecord != null
362
+ && (((terminalRecord.category === 'cancellation')
363
+ !== ['QUERY_CANCELLED', 'QUERY_CANCELLED_AFTER_COMMIT'].includes(terminalRecord.code))
364
+ || ((terminalRecord.category === 'deadline')
365
+ !== ['DEADLINE_EXCEEDED', 'DEADLINE_AFTER_COMMIT'].includes(terminalRecord.code))
366
+ || ((terminalRecord.category === 'result_limit')
367
+ !== (terminalRecord.code === 'RESULT_LIMIT_EXCEEDED'))
368
+ || ((terminalRecord.category === 'serialization')
369
+ !== ['SERIALIZATION_FAILED', 'SERIALIZATION_FAILED_AFTER_COMMIT']
370
+ .includes(terminalRecord.code))))
371
+ return false;
372
+ const terminalMatches = body.status === 'completed' || body.status === 'committed'
373
+ ? terminal == null
374
+ : body.status === 'cancelled_after_commit'
375
+ ? terminalRecord?.code === 'QUERY_CANCELLED_AFTER_COMMIT'
376
+ && terminalRecord.category === 'cancellation'
377
+ : body.status === 'deadline_after_commit'
378
+ ? terminalRecord?.code === 'DEADLINE_AFTER_COMMIT'
379
+ && terminalRecord.category === 'deadline'
380
+ : ['committed_with_error', 'partially_committed'].includes(body.status)
381
+ && terminal != null;
382
+ if (!terminalMatches)
383
+ return false;
384
+ const topEpoch = receiptEpoch(body.last_commit_epoch, body.last_commit_epoch_text);
385
+ const outcomeEpoch = receiptEpoch(durable.last_commit_epoch, durable.last_commit_epoch_text);
386
+ if (topEpoch !== outcomeEpoch)
387
+ return false;
388
+ const topFirst = body.first_commit_statement_index;
389
+ const outcomeFirst = durable.first_commit_statement_index;
390
+ const topLast = body.last_commit_statement_index;
391
+ const outcomeLast = durable.last_commit_statement_index;
392
+ if ((topFirst ?? null) !== (outcomeFirst ?? null) || (topLast ?? null) !== (outcomeLast ?? null)) {
393
+ return false;
394
+ }
395
+ if (body.completed_statements !== durable.completed_statements
396
+ || body.statement_index !== durable.statement_index)
397
+ return false;
398
+ if (topFirst != null && topLast != null && topFirst > topLast)
399
+ return false;
400
+ if (body.committed) {
401
+ if (body.committed_statements === 0 || topEpoch === undefined
402
+ || body.last_commit_epoch_text == null || durable.last_commit_epoch_text == null
403
+ || topFirst == null || topLast == null)
404
+ return false;
405
+ }
406
+ else if (body.committed_statements !== 0 || topEpoch !== undefined
407
+ || topFirst != null || topLast != null)
408
+ return false;
409
+ if (topFirst != null && topLast != null
410
+ && (body.committed_statements > topLast - topFirst + 1
411
+ || topLast > body.statement_index))
412
+ return false;
413
+ if (body.statement_index > body.completed_statements
414
+ || body.completed_statements > body.statement_index + 1)
415
+ return false;
416
+ const originalQueryIdMatches = expectedOriginalQueryId === undefined
417
+ ? body.idempotency_replayed === true || body.original_query_id === queryId
418
+ : body.idempotency_replayed === true
419
+ ? body.original_query_id === expectedOriginalQueryId
420
+ : body.original_query_id === queryId;
421
+ return body.idempotency_persisted === true
422
+ && body.idempotency_expires_at_ms > 0
423
+ && body.retryable === false
424
+ && originalQueryIdMatches;
425
+ }
426
+ const QUERY_STATES = new Set([
427
+ 'queued', 'planning', 'executing', 'streaming', 'serializing', 'commit_critical',
428
+ 'cancelling', 'completed', 'failed', 'cancelled', 'pre_cancelled', 'finished'
429
+ ]);
430
+ const QUERY_STATUSES = new Set([
431
+ 'running', 'outcome_unknown', 'completed', 'failed_before_commit',
432
+ 'cancelled_before_commit', 'deadline_before_commit', 'cancelled_before_start',
433
+ 'committed', 'committed_with_error', 'partially_committed',
434
+ 'cancelled_after_commit', 'deadline_after_commit', 'finished'
435
+ ]);
436
+ const COMMITTED_QUERY_STATUSES = new Set([
437
+ 'committed', 'committed_with_error', 'partially_committed',
438
+ 'cancelled_after_commit', 'deadline_after_commit'
439
+ ]);
440
+ function sameOptional(left, right) {
441
+ return (left ?? null) === (right ?? null);
442
+ }
443
+ function isRemoteQueryStatus(value, queryId) {
444
+ if (value === null || typeof value !== 'object')
445
+ return false;
446
+ const body = value;
447
+ const outcome = body.outcome !== null && typeof body.outcome === 'object'
448
+ ? body.outcome
449
+ : undefined;
450
+ if (outcome === undefined)
451
+ return false;
452
+ const serverState = body.server_state ?? '';
453
+ const terminal = body.terminal_error;
454
+ if (!hasOnlyKeys(body, [
455
+ 'query_id', 'status', 'state', 'server_state', 'terminal_state', 'operation',
456
+ 'started_ms_ago', 'deadline_ms_remaining', 'session_id',
457
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
458
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
459
+ 'statement_index', 'cancel_outcome', 'retryable', 'terminal_error',
460
+ 'cancellation_reason', 'outcome', 'trace'
461
+ ]) || !hasOnlyKeys(outcome, [
462
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
463
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
464
+ 'statement_index', 'serialization'
465
+ ]) || !hasAllKeys(outcome, [
466
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
467
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
468
+ 'statement_index', 'serialization'
469
+ ]))
470
+ return false;
471
+ if (terminal !== undefined && terminal !== null
472
+ && (typeof terminal !== 'object'
473
+ || !hasOnlyKeys(terminal, ['code', 'category'])))
474
+ return false;
475
+ const trace = body.trace;
476
+ if (trace !== undefined && (trace === null || typeof trace !== 'object'
477
+ || !hasOnlyKeys(trace, [
478
+ 'queue_duration_us', 'planning_duration_us', 'execution_duration_us',
479
+ 'serialization_duration_us', 'cancel_requested_phase', 'cancel_observed_phase',
480
+ 'commit_fence_outcome'
481
+ ])))
482
+ return false;
483
+ const traceRecord = trace;
484
+ const tracePhaseValid = (phase) => phase === undefined || phase === null
485
+ || typeof phase === 'string' && QUERY_STATES.has(phase);
486
+ if (traceRecord !== undefined
487
+ && (traceRecord.queue_duration_us !== undefined
488
+ && !isNonNegativeInteger(traceRecord.queue_duration_us)
489
+ || traceRecord.planning_duration_us !== undefined
490
+ && !isNonNegativeInteger(traceRecord.planning_duration_us)
491
+ || traceRecord.execution_duration_us !== undefined
492
+ && !isNonNegativeInteger(traceRecord.execution_duration_us)
493
+ || traceRecord.serialization_duration_us !== undefined
494
+ && !isNonNegativeInteger(traceRecord.serialization_duration_us)
495
+ || !tracePhaseValid(traceRecord.cancel_requested_phase)
496
+ || !tracePhaseValid(traceRecord.cancel_observed_phase)
497
+ || traceRecord.commit_fence_outcome !== undefined
498
+ && !['not_reached', 'cancel_won', 'commit_won']
499
+ .includes(traceRecord.commit_fence_outcome)))
500
+ return false;
501
+ if (body.query_id !== queryId
502
+ || typeof body.status !== 'string' || !QUERY_STATUSES.has(body.status)
503
+ || typeof body.state !== 'string' || !QUERY_STATES.has(body.state)
504
+ || typeof serverState !== 'string'
505
+ || serverState !== '' && (!QUERY_STATES.has(serverState) || serverState !== body.state)
506
+ || body.terminal_state != null && body.terminal_state !== body.status
507
+ || body.started_ms_ago !== undefined && !isNonNegativeInteger(body.started_ms_ago)
508
+ || body.deadline_ms_remaining !== undefined && body.deadline_ms_remaining !== null
509
+ && !isNonNegativeInteger(body.deadline_ms_remaining)
510
+ || body.session_id !== undefined && body.session_id !== null
511
+ && (typeof body.session_id !== 'string' || body.session_id.length > 256)
512
+ || ![undefined, null, true, false].includes(body.committed)
513
+ || ![undefined, null, true, false].includes(outcome.committed)
514
+ || typeof body.retryable !== 'boolean'
515
+ || typeof body.cancellation_reason !== 'string'
516
+ || !['not_started', 'in_progress', 'succeeded', 'failed', 'unknown']
517
+ .includes(outcome.serialization)
518
+ || !isOptionalNonNegativeInteger(body.committed_statements)
519
+ || !isOptionalNonNegativeInteger(body.first_commit_statement_index)
520
+ || !isOptionalNonNegativeInteger(body.last_commit_statement_index)
521
+ || !isOptionalNonNegativeInteger(body.completed_statements)
522
+ || !isOptionalNonNegativeInteger(body.statement_index)
523
+ || !isOptionalNonNegativeInteger(outcome.committed_statements)
524
+ || !isOptionalNonNegativeInteger(outcome.first_commit_statement_index)
525
+ || !isOptionalNonNegativeInteger(outcome.last_commit_statement_index)
526
+ || !isOptionalNonNegativeInteger(outcome.completed_statements)
527
+ || !isOptionalNonNegativeInteger(outcome.statement_index)
528
+ || !isOptionalEpoch(body.last_commit_epoch, body.last_commit_epoch_text)
529
+ || !isOptionalEpoch(outcome.last_commit_epoch, outcome.last_commit_epoch_text)
530
+ || terminal != null && (typeof terminal !== 'object'
531
+ || typeof terminal.code !== 'string'
532
+ || terminal.code.trim().length === 0
533
+ || !['cancellation', 'deadline', 'result_limit', 'serialization', 'execution']
534
+ .includes(terminal.category))) {
535
+ return false;
536
+ }
537
+ const topEpoch = receiptEpoch(body.last_commit_epoch, body.last_commit_epoch_text);
538
+ const outcomeEpoch = receiptEpoch(outcome.last_commit_epoch, outcome.last_commit_epoch_text);
539
+ if (topEpoch !== outcomeEpoch
540
+ || !sameOptional(body.committed, outcome.committed)
541
+ || !sameOptional(body.committed_statements, outcome.committed_statements)
542
+ || !sameOptional(body.first_commit_statement_index, outcome.first_commit_statement_index)
543
+ || !sameOptional(body.last_commit_statement_index, outcome.last_commit_statement_index)
544
+ || !sameOptional(body.completed_statements, outcome.completed_statements)
545
+ || !sameOptional(body.statement_index, outcome.statement_index))
546
+ return false;
547
+ const committed = body.committed ?? null;
548
+ const committedStatements = body.committed_statements;
549
+ const first = body.first_commit_statement_index;
550
+ const last = body.last_commit_statement_index;
551
+ const completed = body.completed_statements;
552
+ const statement = body.statement_index;
553
+ const stateMatchesStatus = body.status === 'running'
554
+ ? ['queued', 'planning', 'executing', 'streaming', 'serializing',
555
+ 'commit_critical', 'cancelling'].includes(body.state)
556
+ : body.status === 'committed'
557
+ ? ['planning', 'executing', 'streaming', 'serializing',
558
+ 'commit_critical', 'cancelling', 'completed'].includes(body.state)
559
+ : body.status === 'completed' ? body.state === 'completed'
560
+ : ['failed_before_commit', 'committed_with_error', 'partially_committed',
561
+ 'outcome_unknown'].includes(body.status) ? body.state === 'failed'
562
+ : ['cancelled_before_commit', 'deadline_before_commit',
563
+ 'cancelled_after_commit', 'deadline_after_commit'].includes(body.status)
564
+ ? body.state === 'cancelled'
565
+ : body.status === 'cancelled_before_start' ? body.state === 'pre_cancelled'
566
+ : body.status === 'finished' && body.state === 'finished';
567
+ if (!stateMatchesStatus)
568
+ return false;
569
+ const expectedTerminal = body.status === 'running' || body.status === 'finished'
570
+ || body.status === 'committed' && body.state !== 'completed'
571
+ ? null : body.status;
572
+ if ((body.terminal_state ?? null) !== expectedTerminal)
573
+ return false;
574
+ if (committed === true) {
575
+ if (!COMMITTED_QUERY_STATUSES.has(body.status)
576
+ || committedStatements == null || committedStatements === 0
577
+ || topEpoch === undefined || body.last_commit_epoch_text == null
578
+ || outcome.last_commit_epoch_text == null || first == null || last == null
579
+ || completed == null || statement == null)
580
+ return false;
581
+ }
582
+ else if (committed === false) {
583
+ if (COMMITTED_QUERY_STATUSES.has(body.status)
584
+ || body.status === 'outcome_unknown' || body.status === 'finished'
585
+ || committedStatements !== 0 || topEpoch !== undefined
586
+ || first != null || last != null || completed == null || statement == null)
587
+ return false;
588
+ }
589
+ else if (body.status !== 'outcome_unknown' && body.status !== 'finished'
590
+ || committedStatements != null || topEpoch !== undefined
591
+ || first != null || last != null || completed != null || statement != null)
592
+ return false;
593
+ if (first != null && last != null && committedStatements != null && statement != null
594
+ && (first > last || committedStatements > last - first + 1 || last > statement))
595
+ return false;
596
+ if (completed != null && statement != null
597
+ && (statement > completed || completed > statement + 1))
598
+ return false;
599
+ const expectedCancel = body.state === 'cancelling' ? 'accepted'
600
+ : body.state === 'commit_critical' ? 'too_late'
601
+ : ['completed', 'failed', 'cancelled', 'finished'].includes(body.state)
602
+ ? 'already_finished'
603
+ : body.state === 'pre_cancelled' ? 'pre_cancelled' : null;
604
+ if ((body.cancel_outcome ?? null) !== expectedCancel)
605
+ return false;
606
+ const terminalError = terminal;
607
+ const terminalCode = terminalError?.code;
608
+ const terminalCategory = terminalError?.category;
609
+ const terminalMatches = ['running', 'completed', 'committed', 'finished']
610
+ .includes(body.status) ? terminal == null
611
+ : body.status === 'outcome_unknown'
612
+ ? terminalCode === 'QUERY_OUTCOME_UNKNOWN' && terminalCategory === 'execution'
613
+ : ['cancelled_before_commit', 'cancelled_before_start'].includes(body.status)
614
+ ? terminalCode === 'QUERY_CANCELLED' && terminalCategory === 'cancellation'
615
+ : body.status === 'cancelled_after_commit'
616
+ ? terminalCode === 'QUERY_CANCELLED_AFTER_COMMIT'
617
+ && terminalCategory === 'cancellation'
618
+ : body.status === 'deadline_before_commit'
619
+ ? terminalCode === 'DEADLINE_EXCEEDED' && terminalCategory === 'deadline'
620
+ : body.status === 'deadline_after_commit'
621
+ ? terminalCode === 'DEADLINE_AFTER_COMMIT'
622
+ && terminalCategory === 'deadline'
623
+ : terminal != null;
624
+ if (!terminalMatches)
625
+ return false;
626
+ if (terminalError != null
627
+ && ((terminalCategory === 'cancellation')
628
+ !== ['QUERY_CANCELLED', 'QUERY_CANCELLED_AFTER_COMMIT'].includes(terminalCode)
629
+ || (terminalCategory === 'deadline')
630
+ !== ['DEADLINE_EXCEEDED', 'DEADLINE_AFTER_COMMIT'].includes(terminalCode))) {
631
+ return false;
632
+ }
633
+ const retryable = ['IDEMPOTENCY_STORE_FULL', 'IDEMPOTENCY_STORE_UNAVAILABLE']
634
+ .includes(terminalCode);
635
+ if (body.retryable !== retryable)
636
+ return false;
637
+ const reason = body.cancellation_reason;
638
+ if (!['none', 'client_request', 'deadline', 'client_disconnected',
639
+ 'session_closed', 'server_shutdown'].includes(reason))
640
+ return false;
641
+ if (['deadline_before_commit', 'deadline_after_commit'].includes(body.status)) {
642
+ return reason === 'deadline';
643
+ }
644
+ if (['cancelled_before_commit', 'cancelled_before_start', 'cancelled_after_commit']
645
+ .includes(body.status)
646
+ || ['running', 'committed'].includes(body.status) && body.state === 'cancelling') {
647
+ return reason !== 'none';
648
+ }
649
+ return reason === 'none' || body.state === 'commit_critical';
650
+ }
651
+ function isRemoteSqlPage(value, initialOptions) {
652
+ if (value === null || typeof value !== 'object')
653
+ return false;
654
+ const body = value;
655
+ if (body.page === null || typeof body.page !== 'object' || !Array.isArray(body.rows))
656
+ return false;
657
+ const page = body.page;
658
+ if (page.limits === null || typeof page.limits !== 'object')
659
+ return false;
660
+ const limits = page.limits;
661
+ if (!hasOnlyKeys(body, ['status', 'rows', 'next_cursor', 'page'])
662
+ || !hasOnlyKeys(page, [
663
+ 'offset', 'row_count', 'total_rows', 'byte_count', 'estimated_tokens', 'limits',
664
+ 'projection', 'expires_at_ms', 'snapshot', 'token_estimate'
665
+ ])
666
+ || !hasOnlyKeys(limits, ['rows', 'bytes', 'tokens']))
667
+ return false;
668
+ const shapeValid = body.status === 'completed'
669
+ && body.rows.every((row) => row !== null && typeof row === 'object' && !Array.isArray(row))
670
+ && (body.next_cursor === undefined || body.next_cursor === null
671
+ || typeof body.next_cursor === 'string' && body.next_cursor.length > 0)
672
+ && isNonNegativeInteger(page.offset)
673
+ && isNonNegativeInteger(page.row_count)
674
+ && page.row_count === body.rows.length
675
+ && isNonNegativeInteger(page.total_rows)
676
+ && page.offset <= page.total_rows
677
+ && page.row_count <= page.total_rows - page.offset
678
+ && isNonNegativeInteger(page.byte_count)
679
+ && isNonNegativeInteger(page.estimated_tokens)
680
+ && isNonNegativeInteger(limits.rows)
681
+ && isNonNegativeInteger(limits.bytes)
682
+ && isNonNegativeInteger(limits.tokens)
683
+ && Array.isArray(page.projection)
684
+ && page.projection.length > 0 && page.projection.length <= 128
685
+ && page.projection.every((column) => typeof column === 'string'
686
+ && column.length > 0 && column !== '*'
687
+ && Buffer.byteLength(column, 'utf8') <= 256)
688
+ && page.projection.reduce((bytes, column) => bytes + Buffer.byteLength(column, 'utf8'), 0) <= 16 * 1024
689
+ && isNonNegativeInteger(page.expires_at_ms)
690
+ && page.snapshot === 'retained_result'
691
+ && page.token_estimate === 'ceil(projected_json_bytes/4)';
692
+ if (!shapeValid)
693
+ return false;
694
+ const projection = page.projection;
695
+ if (new Set(projection).size !== projection.length
696
+ || body.rows.some((row) => {
697
+ const keys = Object.keys(row);
698
+ return keys.length !== projection.length
699
+ || projection.some((column) => !Object.prototype.hasOwnProperty.call(row, column));
700
+ }))
701
+ return false;
702
+ const byteCount = body.rows.reduce((bytes, row, index) => bytes + (index === 0 ? 0 : 1)
703
+ + Buffer.byteLength(JSON.stringify(row), 'utf8'), 2);
704
+ if (page.byte_count !== byteCount
705
+ || page.estimated_tokens !== Math.ceil(byteCount / 4))
706
+ return false;
707
+ if (limits.rows === 0 || limits.bytes === 0
708
+ || limits.tokens === 0
709
+ || limits.bytes > MAX_PAGE_JSON_RESPONSE_BYTES
710
+ || page.row_count > limits.rows
711
+ || page.byte_count > limits.bytes
712
+ || page.estimated_tokens > limits.tokens)
713
+ return false;
714
+ const hasMore = page.offset + page.row_count < page.total_rows;
715
+ if (hasMore && page.row_count === 0
716
+ || hasMore !== (typeof body.next_cursor === 'string'))
717
+ return false;
718
+ if (typeof body.next_cursor === 'string'
719
+ && Buffer.byteLength(body.next_cursor, 'utf8') > 2_048)
720
+ return false;
721
+ if (page.expires_at_ms === 0)
722
+ return false;
723
+ if (initialOptions === undefined)
724
+ return true;
725
+ return page.offset === 0
726
+ && JSON.stringify(page.projection) === JSON.stringify(initialOptions.projection)
727
+ && limits.rows <= initialOptions.pageSizeRows
728
+ && (initialOptions.maxPageBytes === undefined
729
+ || limits.bytes <= initialOptions.maxPageBytes)
730
+ && (initialOptions.maxPageTokens === undefined
731
+ || limits.tokens <= initialOptions.maxPageTokens)
732
+ && (initialOptions.maxOutputRows === undefined
733
+ || page.total_rows <= initialOptions.maxOutputRows)
734
+ && (initialOptions.maxOutputBytes === undefined
735
+ || page.byte_count <= initialOptions.maxOutputBytes);
736
+ }
737
+ function remoteSqlPage(page) {
738
+ return {
739
+ status: page.status,
740
+ rows: page.rows,
741
+ nextCursor: page.next_cursor ?? undefined,
742
+ page: {
743
+ offset: page.page.offset,
744
+ rowCount: page.page.row_count,
745
+ totalRows: page.page.total_rows,
746
+ byteCount: page.page.byte_count,
747
+ estimatedTokens: page.page.estimated_tokens,
748
+ limits: page.page.limits,
749
+ projection: page.page.projection,
750
+ expiresAtMs: page.page.expires_at_ms,
751
+ snapshot: page.page.snapshot,
752
+ tokenEstimate: page.page.token_estimate
753
+ }
754
+ };
755
+ }
756
+ class RetryIdempotentSqlError extends Error {
757
+ outcome;
758
+ constructor(outcome) {
759
+ super(outcome.message);
760
+ this.outcome = outcome;
761
+ }
762
+ }
763
+ class QueryNotRetainedError extends QueryOutcomeUnknownError {
764
+ constructor(queryId, message) {
765
+ super(queryId, message, { retryable: false, serverState: 'not_found' });
766
+ }
767
+ }
768
+ class InvalidQueryStatusError extends QueryOutcomeUnknownError {
769
+ constructor(queryId, message) {
770
+ super(queryId, `invalid query status response: ${message}`, {
771
+ retryable: false,
772
+ serverState: 'invalid_status'
773
+ });
774
+ }
775
+ }
776
+ function remoteEpoch(text, value) {
777
+ if (text !== undefined && text !== null) {
778
+ if (!/^\d+$/.test(text) || BigInt(text) > MAX_U64) {
779
+ throw new KitError('invalid last_commit_epoch_text from server');
780
+ }
781
+ return BigInt(text);
782
+ }
783
+ if (value === undefined || value === null)
784
+ return undefined;
785
+ if (!Number.isSafeInteger(value) || value < 0) {
786
+ throw new KitError('server returned an unsafe numeric commit epoch', 'QUERY_OUTCOME_UNKNOWN');
787
+ }
788
+ return BigInt(value);
789
+ }
790
+ function mergedCommitState(nested, top) {
791
+ if (nested === true || top === true)
792
+ return true;
793
+ return nested ?? top ?? null;
794
+ }
795
+ function recoveryStatusIsDecisive(status) {
796
+ return TERMINAL_SQL_STATES.has(status.state)
797
+ || mergedCommitState(status.outcome?.committed, status.committed) === true;
798
+ }
799
+ function maxKnown(left, right) {
800
+ if (left === undefined || left === null)
801
+ return right ?? undefined;
802
+ if (right === undefined || right === null)
803
+ return left;
804
+ return Math.max(left, right);
805
+ }
806
+ function cancelOutcome(value) {
807
+ return {
808
+ accepted: 'accepted',
809
+ cancellation_requested: 'accepted',
810
+ already_cancelling: 'already_cancelling',
811
+ cancelling: 'already_cancelling',
812
+ too_late: 'too_late',
813
+ commit_critical: 'too_late',
814
+ already_finished: 'already_finished',
815
+ finished: 'already_finished',
816
+ not_found: 'not_found',
817
+ pre_cancelled: 'pre_cancelled'
818
+ }[String(value)];
819
+ }
820
+ function validateCancelResponse(value, queryId, httpStatus) {
821
+ if (value === null || typeof value !== 'object') {
822
+ throw new KitError('SQL cancellation response was not an object', 'REMOTE_PROTOCOL');
823
+ }
824
+ const body = value;
825
+ if (!hasOnlyKeys(body, [
826
+ 'query_id', 'state', 'cancel_outcome', 'status', 'terminal_state', 'code',
827
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
828
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
829
+ 'statement_index', 'cancellation_reason', 'retryable', 'server_state', 'outcome', 'error',
830
+ 'terminal_error'
831
+ ])) {
832
+ throw new KitError('SQL cancellation response has unknown fields', 'REMOTE_PROTOCOL');
833
+ }
834
+ if (body.outcome !== undefined && body.outcome !== null
835
+ && (typeof body.outcome !== 'object'
836
+ || !hasOnlyKeys(body.outcome, [
837
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
838
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
839
+ 'statement_index', 'serialization'
840
+ ]))) {
841
+ throw new KitError('SQL cancellation outcome has unknown fields', 'REMOTE_PROTOCOL');
842
+ }
843
+ if (body.error !== undefined && body.error !== null
844
+ && (typeof body.error !== 'object'
845
+ || !hasOnlyKeys(body.error, [
846
+ 'code', 'message', 'query_id', 'committed', 'retryable'
847
+ ]))) {
848
+ throw new KitError('SQL cancellation error has unknown fields', 'REMOTE_PROTOCOL');
849
+ }
850
+ if (body.terminal_error !== undefined && body.terminal_error !== null
851
+ && (typeof body.terminal_error !== 'object'
852
+ || !hasOnlyKeys(body.terminal_error, [
853
+ 'code', 'category'
854
+ ])
855
+ || typeof body.terminal_error.code !== 'string'
856
+ || body.terminal_error.code.length === 0
857
+ || typeof body.terminal_error.category !== 'string'
858
+ || body.terminal_error.category.length === 0)) {
859
+ throw new KitError('SQL cancellation terminal error has invalid fields', 'REMOTE_PROTOCOL');
860
+ }
861
+ const durable = body.outcome;
862
+ if (durable != null) {
863
+ const required = [
864
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
865
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
866
+ 'statement_index', 'serialization'
867
+ ];
868
+ if (!required.every((field) => Object.prototype.hasOwnProperty.call(durable, field))
869
+ || ![null, true, false].includes(durable.committed)
870
+ || ['committed_statements', 'first_commit_statement_index',
871
+ 'last_commit_statement_index', 'completed_statements', 'statement_index']
872
+ .some((field) => !isOptionalNonNegativeInteger(durable[field]))
873
+ || !isOptionalEpoch(durable.last_commit_epoch, durable.last_commit_epoch_text)
874
+ || !['not_started', 'in_progress', 'succeeded', 'failed', 'unknown']
875
+ .includes(durable.serialization)) {
876
+ throw new KitError('SQL cancellation outcome fields are invalid', 'REMOTE_PROTOCOL');
877
+ }
878
+ }
879
+ if (body.committed !== undefined && ![null, true, false]
880
+ .includes(body.committed)) {
881
+ throw new KitError('SQL cancellation committed field is invalid', 'REMOTE_PROTOCOL');
882
+ }
883
+ for (const field of ['committed_statements', 'first_commit_statement_index',
884
+ 'last_commit_statement_index', 'completed_statements', 'statement_index']) {
885
+ if (body[field] !== undefined && !isOptionalNonNegativeInteger(body[field])) {
886
+ throw new KitError(`SQL cancellation ${field} is invalid`, 'REMOTE_PROTOCOL');
887
+ }
888
+ if (durable != null && body[field] !== undefined
889
+ && !sameOptional(body[field], durable[field])) {
890
+ throw new KitError(`SQL cancellation ${field} disagrees with outcome`, 'REMOTE_PROTOCOL');
891
+ }
892
+ }
893
+ if ((body.last_commit_epoch !== undefined || body.last_commit_epoch_text !== undefined)
894
+ && !isOptionalEpoch(body.last_commit_epoch, body.last_commit_epoch_text)) {
895
+ throw new KitError('SQL cancellation commit epoch is invalid', 'REMOTE_PROTOCOL');
896
+ }
897
+ if (durable != null
898
+ && (body.committed !== undefined && !sameOptional(body.committed, durable.committed)
899
+ || (body.last_commit_epoch !== undefined || body.last_commit_epoch_text !== undefined)
900
+ && receiptEpoch(body.last_commit_epoch, body.last_commit_epoch_text)
901
+ !== receiptEpoch(durable.last_commit_epoch, durable.last_commit_epoch_text))) {
902
+ throw new KitError('SQL cancellation outcome metadata disagrees', 'REMOTE_PROTOCOL');
903
+ }
904
+ if (body.status !== undefined && (typeof body.status !== 'string'
905
+ || !QUERY_STATUSES.has(body.status) && body.status !== 'unknown')) {
906
+ throw new KitError('SQL cancellation status is invalid', 'REMOTE_PROTOCOL');
907
+ }
908
+ if (body.terminal_state !== undefined && body.terminal_state !== null
909
+ && (typeof body.terminal_state !== 'string'
910
+ || body.terminal_state !== body.status)) {
911
+ throw new KitError('SQL cancellation terminal state is invalid', 'REMOTE_PROTOCOL');
912
+ }
913
+ if (body.retryable !== undefined && typeof body.retryable !== 'boolean') {
914
+ throw new KitError('SQL cancellation retryable field is invalid', 'REMOTE_PROTOCOL');
915
+ }
916
+ if (body.server_state !== undefined && (typeof body.server_state !== 'string'
917
+ || !QUERY_STATES.has(body.server_state) && body.server_state !== 'not_found')) {
918
+ throw new KitError('SQL cancellation server state is invalid', 'REMOTE_PROTOCOL');
919
+ }
920
+ if (body.cancellation_reason !== undefined && body.cancellation_reason !== null
921
+ && (typeof body.cancellation_reason !== 'string'
922
+ || !['none', 'client_request', 'deadline', 'client_disconnected',
923
+ 'session_closed', 'server_shutdown'].includes(body.cancellation_reason))) {
924
+ throw new KitError('SQL cancellation reason is invalid', 'REMOTE_PROTOCOL');
925
+ }
926
+ const detail = body.error;
927
+ if (detail != null
928
+ && (typeof detail.code !== 'string' || detail.code.length === 0
929
+ || typeof detail.message !== 'string' || detail.message.length === 0
930
+ || detail.query_id !== undefined && detail.query_id !== null
931
+ && typeof detail.query_id !== 'string'
932
+ || detail.committed !== undefined
933
+ && ![null, true, false].includes(detail.committed)
934
+ || detail.retryable !== undefined && typeof detail.retryable !== 'boolean')) {
935
+ throw new KitError('SQL cancellation error fields are invalid', 'REMOTE_PROTOCOL');
936
+ }
937
+ if (body.query_id !== queryId) {
938
+ throw new KitError('SQL cancellation query ID does not match the request', 'REMOTE_PROTOCOL');
939
+ }
940
+ const decodeField = (name) => {
941
+ const raw = body[name];
942
+ if (raw === undefined || raw === null)
943
+ return undefined;
944
+ const decoded = cancelOutcome(raw);
945
+ if (decoded === undefined) {
946
+ throw new KitError(`SQL cancellation ${name} is invalid`, 'REMOTE_PROTOCOL');
947
+ }
948
+ return decoded;
949
+ };
950
+ const outcome = decodeField('cancel_outcome');
951
+ let state = decodeField('state');
952
+ if (outcome === 'not_found' && state === undefined && body.server_state === 'not_found') {
953
+ state = 'not_found';
954
+ }
955
+ if (outcome === undefined || state === undefined) {
956
+ throw new KitError('SQL cancellation state and outcome are required', 'REMOTE_PROTOCOL');
957
+ }
958
+ if (outcome !== state) {
959
+ throw new KitError('SQL cancellation state and outcome disagree', 'REMOTE_PROTOCOL');
960
+ }
961
+ const result = outcome;
962
+ const compatible = httpStatus === 202 && ['accepted', 'pre_cancelled'].includes(result ?? '')
963
+ || httpStatus === 200 && ['already_cancelling', 'already_finished'].includes(result ?? '')
964
+ || httpStatus === 409 && result === 'too_late'
965
+ || httpStatus === 404 && result === 'not_found';
966
+ if (!compatible) {
967
+ throw new KitError('SQL cancellation HTTP status and outcome disagree', 'REMOTE_PROTOCOL');
968
+ }
969
+ const terminal = body.terminal_error;
970
+ if (result === 'pre_cancelled') {
971
+ if (terminal != null
972
+ && (terminal.code !== 'QUERY_CANCELLED' || terminal.category !== 'cancellation')) {
973
+ throw new KitError('SQL cancellation terminal error disagrees with outcome', 'REMOTE_PROTOCOL');
974
+ }
975
+ }
976
+ else if (terminal != null) {
977
+ throw new KitError('SQL cancellation terminal error disagrees with outcome', 'REMOTE_PROTOCOL');
978
+ }
979
+ return result;
980
+ }
981
+ function exactNativeValue(values, valid) {
982
+ let exact = undefined;
983
+ for (const value of values) {
984
+ if (value === undefined)
985
+ continue;
986
+ if (!valid(value) || exact !== undefined && !Object.is(exact, value)) {
987
+ throw new Error('conflicting native response fields');
988
+ }
989
+ exact = value;
990
+ }
991
+ return exact;
992
+ }
993
+ function nativeRemoteWriteEpoch(...sources) {
994
+ let exact;
995
+ const merge = (value, text) => {
996
+ if (value === undefined || value === null)
997
+ return;
998
+ let parsed;
999
+ if (text) {
1000
+ if (typeof value !== 'string' || !/^\d+$/u.test(value)
1001
+ || BigInt(value).toString() !== value)
1002
+ throw new Error('invalid exact commit epoch');
1003
+ parsed = BigInt(value);
1004
+ }
1005
+ else if (typeof value === 'bigint')
1006
+ parsed = value;
1007
+ else if (typeof value === 'number' && Number.isSafeInteger(value))
1008
+ parsed = BigInt(value);
1009
+ else if (typeof value === 'string' && /^\d+$/u.test(value)
1010
+ && BigInt(value).toString() === value)
1011
+ parsed = BigInt(value);
1012
+ else
1013
+ throw new Error('invalid commit epoch');
1014
+ if (parsed < 0n || parsed > MAX_U64)
1015
+ throw new Error('commit epoch outside u64 range');
1016
+ if (exact !== undefined && exact !== parsed)
1017
+ throw new Error('conflicting commit epoch fields');
1018
+ exact = parsed;
1019
+ };
1020
+ for (const source of sources) {
1021
+ merge(source.lastCommitEpoch, false);
1022
+ merge(source.epoch, false);
1023
+ merge(source.lastCommitEpochText, true);
1024
+ merge(source.epochText, true);
1025
+ }
1026
+ return exact;
1027
+ }
1028
+ function nativeRemoteWriteError(error) {
1029
+ const original = error instanceof Error ? error : new Error(String(error));
1030
+ if (error instanceof KitError)
1031
+ return error;
1032
+ if (error === null || typeof error !== 'object')
1033
+ return original;
1034
+ const top = error;
1035
+ const details = top.remoteQueryError !== null && typeof top.remoteQueryError === 'object'
1036
+ ? top.remoteQueryError
1037
+ : top;
1038
+ const candidateCodes = [top.code, details.code].filter((value) => typeof value === 'string');
1039
+ if (!candidateCodes.some((code) => code === 'COMMIT_OUTCOME'
1040
+ || code === 'QUERY_OUTCOME_UNKNOWN'))
1041
+ return original;
1042
+ try {
1043
+ if (details !== top && !hasOnlyKeys(details, [
1044
+ 'code', 'message', 'queryId', 'status', 'httpStatus', 'outcomeKnown', 'committed',
1045
+ 'epoch', 'epochText', 'committedStatements', 'lastCommitEpoch',
1046
+ 'lastCommitEpochText', 'firstCommitStatementIndex', 'lastCommitStatementIndex',
1047
+ 'completedStatements', 'statementIndex', 'cancelOutcome', 'cancellationReason',
1048
+ 'retryable', 'serverState', 'terminalState'
1049
+ ]))
1050
+ throw new Error('unknown native response field');
1051
+ const code = exactNativeValue([top.code, details.code], (value) => value === 'COMMIT_OUTCOME' || value === 'QUERY_OUTCOME_UNKNOWN');
1052
+ const queryId = exactNativeValue([top.queryId, top.query_id, details.queryId, details.query_id], (value) => typeof value === 'string'
1053
+ && (value === 'unknown' || /^[0-9a-f]{32}$/u.test(value))) ?? 'unknown';
1054
+ const message = exactNativeValue([top.message, details.message], (value) => typeof value === 'string') ?? original.message;
1055
+ const retryable = exactNativeValue([top.retryable, details.retryable], (value) => typeof value === 'boolean');
1056
+ const serverState = exactNativeValue([top.serverState, details.serverState], (value) => typeof value === 'string');
1057
+ const status = exactNativeValue([top.status, details.status], (value) => typeof value === 'string');
1058
+ const committed = exactNativeValue([top.committed, details.committed], (value) => value === null || typeof value === 'boolean');
1059
+ const outcomeKnown = exactNativeValue([top.outcomeKnown, details.outcomeKnown], (value) => typeof value === 'boolean');
1060
+ for (const field of [
1061
+ 'committedStatements', 'firstCommitStatementIndex', 'lastCommitStatementIndex',
1062
+ 'completedStatements', 'statementIndex'
1063
+ ]) {
1064
+ exactNativeValue([top[field], details[field]], (value) => value === null || isNonNegativeInteger(value));
1065
+ }
1066
+ for (const field of [
1067
+ 'cancelOutcome', 'cancellationReason', 'terminalState'
1068
+ ]) {
1069
+ exactNativeValue([top[field], details[field]], (value) => value === null || typeof value === 'string');
1070
+ }
1071
+ const lastCommitEpoch = nativeRemoteWriteEpoch(details, top);
1072
+ const metadata = {
1073
+ retryable,
1074
+ serverState: serverState ?? status
1075
+ };
1076
+ if (code === 'QUERY_OUTCOME_UNKNOWN') {
1077
+ if (committed !== null || outcomeKnown !== false || retryable !== false) {
1078
+ throw new Error('invalid unknown outcome metadata');
1079
+ }
1080
+ return new QueryOutcomeUnknownError(queryId, message, metadata);
1081
+ }
1082
+ if (committed !== true || outcomeKnown !== true || retryable !== false
1083
+ || lastCommitEpoch === undefined) {
1084
+ throw new Error('invalid committed outcome metadata');
1085
+ }
1086
+ return new CommitOutcomeError(queryId, message || 'remote write committed but response failed', { committed: true, lastCommitEpoch }, metadata);
1087
+ }
1088
+ catch {
1089
+ return new QueryOutcomeUnknownError('unknown', 'invalid committed outcome metadata from server', { retryable: false, serverState: 'invalid_outcome' });
1090
+ }
1091
+ }
1092
+ function parseRemoteWriteJson(response, operation, commitProven = true) {
1093
+ try {
1094
+ return parseJsonStrict(response);
1095
+ }
1096
+ catch {
1097
+ if (!commitProven) {
1098
+ throw new QueryOutcomeUnknownError('unknown', `remote ${operation} finished but its response was invalid`, { retryable: false, serverState: 'invalid_response' });
1099
+ }
1100
+ throw new CommitOutcomeError('unknown', `remote ${operation} committed but its response was invalid`, { committed: true }, { retryable: false, serverState: 'invalid_response' });
1101
+ }
1102
+ }
1103
+ function parseProcedureCallResponse(response) {
1104
+ const value = parseRemoteWriteJson(response, 'procedure call', false);
1105
+ const invalid = () => {
1106
+ throw new QueryOutcomeUnknownError('unknown', 'remote procedure call returned invalid commit metadata', { retryable: false, serverState: 'invalid_response' });
1107
+ };
1108
+ if (value === null || typeof value !== 'object')
1109
+ return invalid();
1110
+ const body = value;
1111
+ const fields = ['status', 'committed', 'epoch', 'epoch_text', 'result'];
1112
+ if (!hasOnlyKeys(body, fields) || !hasAllKeys(body, fields)
1113
+ || body.status !== 'ok' || typeof body.committed !== 'boolean')
1114
+ return invalid();
1115
+ if (body.committed) {
1116
+ if (!isNonNegativeInteger(body.epoch) || typeof body.epoch_text !== 'string'
1117
+ || !isOptionalEpoch(body.epoch, body.epoch_text))
1118
+ return invalid();
1119
+ }
1120
+ else if (body.epoch !== null || body.epoch_text !== null)
1121
+ return invalid();
1122
+ return value;
1123
+ }
8
1124
  /**
9
1125
  * A thin Kit client for a running `mongreldb-server` daemon.
10
1126
  *
@@ -20,11 +1136,70 @@ import { createVirtualTableSql, dropVirtualTableSql } from './external.js';
20
1136
  export class RemoteDatabase {
21
1137
  inner;
22
1138
  url;
1139
+ #authorization;
23
1140
  capabilities;
24
1141
  /** Connect (lazily) to a daemon at `url`, e.g. `http://127.0.0.1:8453`. */
25
- constructor(url) {
26
- this.url = url.replace(/\/$/, '');
27
- this.inner = new NativeRemoteDatabase(this.url);
1142
+ constructor(url, options = {}) {
1143
+ let parsed;
1144
+ try {
1145
+ parsed = new URL(url);
1146
+ }
1147
+ catch {
1148
+ throw new TypeError('remote URL must be a valid http:// or https:// URL');
1149
+ }
1150
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
1151
+ throw new TypeError('remote URL must use http:// or https:// and include a host');
1152
+ }
1153
+ if (parsed.username || parsed.password) {
1154
+ throw new TypeError('remote credentials must use the auth option, not the URL');
1155
+ }
1156
+ if (parsed.search || parsed.hash) {
1157
+ throw new TypeError('remote URL must not include a query or fragment');
1158
+ }
1159
+ if (options.auth && 'bearerToken' in options.auth
1160
+ && (typeof options.auth.bearerToken !== 'string'
1161
+ || options.auth.bearerToken.trim().length === 0
1162
+ || containsHttpHeaderControl(options.auth.bearerToken))) {
1163
+ throw new TypeError('bearer token must not be empty');
1164
+ }
1165
+ if (options.auth && !('bearerToken' in options.auth)
1166
+ && (typeof options.auth.username !== 'string'
1167
+ || options.auth.username.length === 0
1168
+ || options.auth.username.includes(':')
1169
+ || containsHttpHeaderControl(options.auth.username)
1170
+ || typeof options.auth.password !== 'string'
1171
+ || containsHttpHeaderControl(options.auth.password))) {
1172
+ throw new TypeError('basic-auth username must be non-empty and contain no colon');
1173
+ }
1174
+ this.url = `${parsed.origin}${parsed.pathname}`.replace(/\/$/, '');
1175
+ const nativeOptions = options.auth && 'bearerToken' in options.auth
1176
+ ? { bearerToken: options.auth.bearerToken }
1177
+ : options.auth
1178
+ ? { username: options.auth.username, password: options.auth.password }
1179
+ : undefined;
1180
+ this.#authorization = options.auth && 'bearerToken' in options.auth
1181
+ ? `Bearer ${options.auth.bearerToken}`
1182
+ : options.auth
1183
+ ? `Basic ${Buffer.from(`${options.auth.username}:${options.auth.password}`, 'utf8').toString('base64')}`
1184
+ : undefined;
1185
+ const NativeRemote = NativeRemoteDatabase;
1186
+ this.inner = new NativeRemote(this.url, nativeOptions);
1187
+ }
1188
+ request(path, init = {}) {
1189
+ const headers = new Headers(init.headers);
1190
+ if (this.#authorization)
1191
+ headers.set('authorization', this.#authorization);
1192
+ return fetch(`${this.url}${path}`, { ...init, headers });
1193
+ }
1194
+ async recoveryRequest(path, init, timeoutMs) {
1195
+ const controller = new AbortController();
1196
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1197
+ try {
1198
+ return await this.request(path, { ...init, signal: controller.signal });
1199
+ }
1200
+ finally {
1201
+ clearTimeout(timer);
1202
+ }
28
1203
  }
29
1204
  /** Liveness check; returns the server's health string (throws if down). */
30
1205
  health() {
@@ -49,119 +1224,631 @@ export class RemoteDatabase {
49
1224
  }
50
1225
  /** Run a SQL query without blocking the Node event loop. */
51
1226
  async sql(sql, options) {
52
- if (options?.timeoutMs !== undefined || options?.signal !== undefined || options?.queryId !== undefined) {
53
- return this.startSql(sql, options).result;
54
- }
55
- return this.executeSql(sql);
1227
+ return this.startSql(sql, options).result;
56
1228
  }
57
1229
  async sqlRows(sql, options) {
58
1230
  return [...(await this.sql(sql, options))].map((row) => ({ ...row }));
59
1231
  }
60
1232
  startSql(sql, options = {}) {
61
- const queryId = options.queryId ?? randomBytes(16).toString('hex');
1233
+ const queryId = normalizeQueryId(options.queryId ?? randomBytes(16).toString('hex'));
62
1234
  if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) {
63
1235
  throw new RangeError('timeoutMs must be a positive safe integer');
64
1236
  }
1237
+ for (const [name, value] of [['maxOutputRows', options.maxOutputRows], ['maxOutputBytes', options.maxOutputBytes]]) {
1238
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
1239
+ throw new RangeError(`${name} must be a positive safe integer`);
1240
+ }
1241
+ }
65
1242
  if (options.signal?.aborted) {
66
1243
  return {
67
1244
  id: queryId,
68
- result: Promise.reject(new QueryCancelledError(queryId)),
69
- cancel: async () => { }
1245
+ result: Promise.reject(preExecutionCancellationError(queryId, 'pre_cancelled')),
1246
+ cancel: async () => 'pre_cancelled',
1247
+ status: async () => ({
1248
+ queryId,
1249
+ phase: 'pre_cancelled',
1250
+ terminalState: 'cancelled_before_start',
1251
+ serverState: 'pre_cancelled',
1252
+ operation: 'sql',
1253
+ committed: false,
1254
+ durableOutcome: { committed: false, committedStatements: 0 },
1255
+ terminalErrorCode: 'QUERY_CANCELLED',
1256
+ completedStatements: 0,
1257
+ statementIndex: 0,
1258
+ cancellationReason: 'client_request',
1259
+ cancelOutcome: 'pre_cancelled',
1260
+ retryable: false
1261
+ })
70
1262
  };
71
1263
  }
72
1264
  const controller = new AbortController();
73
- let cancelled = false;
74
- const cancel = async () => {
75
- if (cancelled)
76
- return;
77
- cancelled = true;
78
- const request = this.cancelSql(queryId);
79
- controller.abort();
80
- await request;
1265
+ let executionStarted = false;
1266
+ let cancelPromise;
1267
+ let signalCancelPromise;
1268
+ const applyCancelOutcome = (outcome) => {
1269
+ if (outcome === 'accepted' || outcome === 'already_cancelling' || outcome === 'pre_cancelled') {
1270
+ controller.abort();
1271
+ }
1272
+ return outcome;
1273
+ };
1274
+ const cancel = () => {
1275
+ cancelPromise ??= this.cancelSql(queryId).then(applyCancelOutcome);
1276
+ return cancelPromise;
1277
+ };
1278
+ const cancelForSignal = () => {
1279
+ signalCancelPromise ??= this.cancelSqlForSignal(queryId).then(applyCancelOutcome);
1280
+ return signalCancelPromise;
81
1281
  };
82
1282
  const onAbort = () => {
83
- void cancel();
1283
+ void cancelForSignal().catch(() => controller.abort());
84
1284
  };
85
1285
  options.signal?.addEventListener('abort', onAbort, { once: true });
86
1286
  const result = this.requireSqlCancellation()
87
- .then(() => this.executeSql(sql, queryId, options.timeoutMs, controller.signal))
88
- .catch((error) => {
89
- if (cancelled || options.signal?.aborted) {
1287
+ .then(async () => {
1288
+ if (options.signal?.aborted) {
1289
+ const outcome = await cancelForSignal();
1290
+ throw preExecutionCancellationError(queryId, outcome);
1291
+ }
1292
+ executionStarted = true;
1293
+ return this.executeSql(sql, queryId, options.timeoutMs, options.maxOutputRows, options.maxOutputBytes, controller.signal);
1294
+ })
1295
+ .catch(async (error) => {
1296
+ const outcome = await (signalCancelPromise ?? cancelPromise)?.catch(() => undefined);
1297
+ if (!executionStarted && options.signal?.aborted) {
1298
+ if (error instanceof CapabilityUnsupportedError)
1299
+ throw error;
1300
+ if (error instanceof KitError && outcome === undefined)
1301
+ throw error;
1302
+ throw preExecutionCancellationError(queryId, outcome);
1303
+ }
1304
+ if (outcome === 'pre_cancelled') {
90
1305
  throw new QueryCancelledError(queryId);
91
1306
  }
92
- throw error;
1307
+ if (outcome === 'accepted' || outcome === 'already_cancelling'
1308
+ || outcome === 'too_late' || outcome === 'already_finished') {
1309
+ return this.resolveLostQueryOutcome(queryId, error);
1310
+ }
1311
+ if (error instanceof KitError)
1312
+ throw error;
1313
+ return this.resolveLostQueryOutcome(queryId, error);
93
1314
  })
94
1315
  .finally(() => options.signal?.removeEventListener('abort', onAbort));
95
- return { id: queryId, result, cancel };
1316
+ return { id: queryId, result, cancel, status: () => this.queryStatus(queryId) };
96
1317
  }
97
1318
  async cancelSql(queryId) {
1319
+ queryId = normalizeQueryId(queryId);
98
1320
  await this.requireSqlCancellation();
99
- const response = await fetch(`${this.url}/queries/${queryId}/cancel`, { method: 'POST' });
100
- if (response.ok || response.status === 409 || response.status === 404)
101
- return;
1321
+ return this.sendCancelSql(queryId);
1322
+ }
1323
+ async cancelSqlForSignal(queryId) {
1324
+ await this.requireSqlCancellation();
1325
+ return this.sendCancelSql(queryId, SQL_RECOVERY_REQUEST_TIMEOUT_MS);
1326
+ }
1327
+ async sendCancelSql(queryId, timeoutMs) {
1328
+ const path = `/queries/${queryId}/cancel`;
1329
+ const response = timeoutMs === undefined
1330
+ ? await this.request(path, { method: 'POST' })
1331
+ : await this.recoveryRequest(path, { method: 'POST' }, timeoutMs);
1332
+ if ([200, 202, 404, 409].includes(response.status)) {
1333
+ const body = await responseJsonStrict(response).catch(() => {
1334
+ throw new KitError('SQL cancellation response was not valid JSON', 'REMOTE_PROTOCOL');
1335
+ });
1336
+ return validateCancelResponse(body, queryId, response.status);
1337
+ }
102
1338
  throw new KitError(`SQL cancellation failed with HTTP ${response.status}`);
103
1339
  }
104
- async executeSql(sql, queryId, timeoutMs, signal) {
105
- const response = await fetch(`${this.url}/sql`, {
1340
+ async queryStatus(queryId) {
1341
+ queryId = normalizeQueryId(queryId);
1342
+ const status = await this.rawQueryStatus(queryId);
1343
+ const committed = mergedCommitState(status.outcome?.committed, status.committed);
1344
+ return {
1345
+ queryId: status.query_id,
1346
+ phase: status.server_state || status.state,
1347
+ terminalState: status.terminal_state ?? undefined,
1348
+ serverState: status.server_state || status.state,
1349
+ operation: status.operation ?? 'sql',
1350
+ committed,
1351
+ durableOutcome: {
1352
+ committed,
1353
+ committedStatements: maxKnown(status.outcome?.committed_statements, status.committed_statements) ?? null,
1354
+ lastCommitEpoch: remoteEpoch(status.outcome?.last_commit_epoch_text ?? status.last_commit_epoch_text, status.outcome?.last_commit_epoch ?? status.last_commit_epoch),
1355
+ firstCommitStatementIndex: status.outcome?.first_commit_statement_index
1356
+ ?? status.first_commit_statement_index ?? undefined,
1357
+ lastCommitStatementIndex: status.outcome?.last_commit_statement_index
1358
+ ?? status.last_commit_statement_index ?? undefined
1359
+ },
1360
+ terminalErrorCode: status.terminal_error?.code,
1361
+ terminalErrorCategory: status.terminal_error?.category,
1362
+ completedStatements: status.outcome?.completed_statements ?? status.completed_statements ?? null,
1363
+ statementIndex: status.outcome?.statement_index ?? status.statement_index ?? null,
1364
+ cancellationReason: status.cancellation_reason ?? 'none',
1365
+ cancelOutcome: status.cancel_outcome ?? undefined,
1366
+ retryable: status.retryable
1367
+ };
1368
+ }
1369
+ async sqlPage(sql, options) {
1370
+ await this.requireSqlPagination();
1371
+ if (options.projection.length === 0 || options.projection.length > 128
1372
+ || options.projection.some((column) => typeof column !== 'string'
1373
+ || column.length === 0 || column === '*'
1374
+ || Buffer.byteLength(column, 'utf8') > 256)
1375
+ || new Set(options.projection).size !== options.projection.length
1376
+ || options.projection.reduce((bytes, column) => bytes + Buffer.byteLength(column, 'utf8'), 0) > 16 * 1024) {
1377
+ throw new RangeError('projection must contain 1 to 128 unique explicit columns');
1378
+ }
1379
+ for (const [name, value] of [
1380
+ ['pageSizeRows', options.pageSizeRows],
1381
+ ['timeoutMs', options.timeoutMs],
1382
+ ['maxPageBytes', options.maxPageBytes],
1383
+ ['maxPageTokens', options.maxPageTokens],
1384
+ ['maxOutputRows', options.maxOutputRows],
1385
+ ['maxOutputBytes', options.maxOutputBytes]
1386
+ ]) {
1387
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
1388
+ throw new RangeError(`${name} must be a positive safe integer`);
1389
+ }
1390
+ }
1391
+ const queryId = normalizeQueryId(options.queryId ?? randomBytes(16).toString('hex'));
1392
+ let response;
1393
+ try {
1394
+ response = await this.request('/sql', {
1395
+ method: 'POST',
1396
+ headers: { 'content-type': 'application/json' },
1397
+ body: JSON.stringify({
1398
+ sql,
1399
+ format: 'json',
1400
+ query_id: queryId,
1401
+ timeout_ms: options.timeoutMs,
1402
+ max_output_rows: options.maxOutputRows,
1403
+ max_output_bytes: options.maxOutputBytes,
1404
+ pagination: {
1405
+ page_size_rows: options.pageSizeRows,
1406
+ projection: options.projection,
1407
+ max_page_bytes: options.maxPageBytes,
1408
+ max_page_tokens: options.maxPageTokens
1409
+ }
1410
+ })
1411
+ });
1412
+ }
1413
+ catch (error) {
1414
+ return this.resolveLostQueryOutcome(queryId, error);
1415
+ }
1416
+ if (!response.ok) {
1417
+ const error = await remoteSqlError(response, queryId);
1418
+ if (error instanceof QueryOutcomeUnknownError) {
1419
+ return this.resolveLostQueryOutcome(queryId, error);
1420
+ }
1421
+ throw error;
1422
+ }
1423
+ if (response.headers.get('x-mongreldb-query-id') !== queryId) {
1424
+ return this.resolveLostQueryOutcome(queryId, new Error('SQL response x-mongreldb-query-id does not match the request'));
1425
+ }
1426
+ const body = await responseJsonStrict(response, MAX_PAGE_JSON_RESPONSE_BYTES).catch((error) => this.resolveLostQueryOutcome(queryId, error));
1427
+ if (!isRemoteSqlPage(body, options)) {
1428
+ return this.resolveLostQueryOutcome(queryId, new Error('SQL pagination response was not a valid retained page'));
1429
+ }
1430
+ return remoteSqlPage(body);
1431
+ }
1432
+ async continueSqlPage(cursor) {
1433
+ await this.requireSqlPagination();
1434
+ if (cursor.length === 0 || Buffer.byteLength(cursor, 'utf8') > 2_048) {
1435
+ throw new RangeError('cursor must contain 1 to 2048 UTF-8 bytes');
1436
+ }
1437
+ const response = await this.request('/sql/continue', {
1438
+ method: 'POST',
1439
+ headers: { 'content-type': 'application/json' },
1440
+ body: JSON.stringify({ cursor })
1441
+ });
1442
+ if (!response.ok)
1443
+ throw await remoteSqlError(response, 'unknown');
1444
+ const body = await responseJsonStrict(response, MAX_PAGE_JSON_RESPONSE_BYTES).catch((error) => {
1445
+ throw new SerializationError('unknown', error instanceof Error ? error.message : 'SQL continuation response was not valid JSON', { committed: false });
1446
+ });
1447
+ if (!isRemoteSqlPage(body)) {
1448
+ throw new SerializationError('unknown', 'SQL continuation response was not a valid retained page', { committed: false });
1449
+ }
1450
+ return remoteSqlPage(body);
1451
+ }
1452
+ async executeIdempotentSql(sql, options) {
1453
+ await this.requireSqlIdempotency();
1454
+ if (options.idempotencyKey.length === 0
1455
+ || Buffer.byteLength(options.idempotencyKey, 'utf8') > 256) {
1456
+ throw new RangeError('idempotencyKey must contain 1 to 256 bytes');
1457
+ }
1458
+ for (const [name, value] of [
1459
+ ['timeoutMs', options.timeoutMs],
1460
+ ['maxOutputRows', options.maxOutputRows],
1461
+ ['maxOutputBytes', options.maxOutputBytes]
1462
+ ]) {
1463
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
1464
+ throw new RangeError(`${name} must be a positive safe integer`);
1465
+ }
1466
+ }
1467
+ const queryId = normalizeQueryId(options.queryId ?? randomBytes(16).toString('hex'));
1468
+ try {
1469
+ return await this.executeIdempotentSqlOnce(sql, options, queryId);
1470
+ }
1471
+ catch (error) {
1472
+ if (!(error instanceof RetryIdempotentSqlError))
1473
+ throw error;
1474
+ await this.requireFreshSqlIdempotency();
1475
+ let replayQueryId = randomBytes(16).toString('hex');
1476
+ while (replayQueryId === queryId)
1477
+ replayQueryId = randomBytes(16).toString('hex');
1478
+ try {
1479
+ return await this.executeIdempotentSqlOnce(sql, options, replayQueryId, queryId);
1480
+ }
1481
+ catch (replayError) {
1482
+ if (replayError instanceof RetryIdempotentSqlError)
1483
+ throw replayError.outcome;
1484
+ throw replayError;
1485
+ }
1486
+ }
1487
+ }
1488
+ async executeIdempotentSqlOnce(sql, options, queryId, expectedOriginalQueryId) {
1489
+ let response;
1490
+ try {
1491
+ response = await this.request('/sql', {
1492
+ method: 'POST',
1493
+ headers: { 'content-type': 'application/json' },
1494
+ body: JSON.stringify({
1495
+ sql,
1496
+ format: 'json',
1497
+ query_id: queryId,
1498
+ timeout_ms: options.timeoutMs,
1499
+ max_output_rows: options.maxOutputRows,
1500
+ max_output_bytes: options.maxOutputBytes,
1501
+ idempotency_key: options.idempotencyKey
1502
+ })
1503
+ });
1504
+ }
1505
+ catch (error) {
1506
+ return this.resolveIdempotentSqlLoss(queryId, error);
1507
+ }
1508
+ if (!response.ok) {
1509
+ const error = await remoteSqlError(response, queryId);
1510
+ if (error instanceof QueryOutcomeUnknownError) {
1511
+ return this.resolveIdempotentSqlLoss(queryId, error);
1512
+ }
1513
+ throw error;
1514
+ }
1515
+ const body = await responseJsonStrict(response).catch((error) => this.resolveIdempotentSqlLoss(queryId, error));
1516
+ if (!isRemoteSqlWriteReceipt(body, queryId, expectedOriginalQueryId)) {
1517
+ return this.resolveIdempotentSqlLoss(queryId, new Error('SQL idempotency response was not a valid durable receipt'));
1518
+ }
1519
+ const receipt = {
1520
+ queryId: body.query_id,
1521
+ originalQueryId: body.original_query_id,
1522
+ status: body.status,
1523
+ terminalState: body.terminal_state ?? undefined,
1524
+ serverState: body.server_state ?? undefined,
1525
+ cancelOutcome: body.cancel_outcome ?? undefined,
1526
+ cancellationReason: body.cancellation_reason ?? undefined,
1527
+ committed: body.committed,
1528
+ committedStatements: body.committed_statements,
1529
+ lastCommitEpoch: remoteEpoch(body.outcome.last_commit_epoch_text ?? body.last_commit_epoch_text, body.outcome.last_commit_epoch ?? body.last_commit_epoch),
1530
+ firstCommitStatementIndex: body.first_commit_statement_index
1531
+ ?? body.outcome.first_commit_statement_index
1532
+ ?? undefined,
1533
+ lastCommitStatementIndex: body.last_commit_statement_index
1534
+ ?? body.outcome.last_commit_statement_index
1535
+ ?? undefined,
1536
+ completedStatements: body.completed_statements,
1537
+ statementIndex: body.statement_index,
1538
+ retryable: body.retryable,
1539
+ idempotencyReplayed: body.idempotency_replayed,
1540
+ idempotencyPersisted: body.idempotency_persisted,
1541
+ idempotencyExpiresAtMs: body.idempotency_expires_at_ms,
1542
+ terminalError: body.terminal_error ?? undefined
1543
+ };
1544
+ if (response.headers.get('x-mongreldb-query-id') !== queryId) {
1545
+ if (receipt.committed) {
1546
+ throw new CommitOutcomeError(queryId, 'SQL committed but x-mongreldb-query-id did not match the request', {
1547
+ committed: true,
1548
+ committedStatements: receipt.committedStatements,
1549
+ lastCommitEpoch: receipt.lastCommitEpoch,
1550
+ firstCommitStatementIndex: receipt.firstCommitStatementIndex,
1551
+ lastCommitStatementIndex: receipt.lastCommitStatementIndex,
1552
+ completedStatements: receipt.completedStatements,
1553
+ statementIndex: receipt.statementIndex
1554
+ }, {
1555
+ cancelOutcome: receipt.cancelOutcome,
1556
+ cancellationReason: receipt.cancellationReason,
1557
+ retryable: false,
1558
+ serverState: receipt.serverState
1559
+ });
1560
+ }
1561
+ return this.resolveIdempotentSqlLoss(queryId, new Error('SQL response x-mongreldb-query-id does not match the request'));
1562
+ }
1563
+ return receipt;
1564
+ }
1565
+ async resolveIdempotentSqlLoss(queryId, cause) {
1566
+ let status;
1567
+ try {
1568
+ status = await this.rawQueryStatus(queryId, SQL_RECOVERY_REQUEST_TIMEOUT_MS);
1569
+ }
1570
+ catch (error) {
1571
+ if (error instanceof QueryNotRetainedError) {
1572
+ throw new RetryIdempotentSqlError(error);
1573
+ }
1574
+ if (!(error instanceof QueryOutcomeUnknownError)) {
1575
+ return this.resolveLostQueryOutcome(queryId, cause);
1576
+ }
1577
+ throw error;
1578
+ }
1579
+ return this.resolveLostQueryOutcome(queryId, cause, status);
1580
+ }
1581
+ async rawQueryStatus(queryId, recoveryTimeoutMs) {
1582
+ await this.requireSqlCancellation();
1583
+ const path = `/queries/${queryId}`;
1584
+ const response = recoveryTimeoutMs === undefined
1585
+ ? await this.request(path)
1586
+ : await this.recoveryRequest(path, {}, recoveryTimeoutMs);
1587
+ if (response.status === 404) {
1588
+ const body = await responseJsonStrict(response).catch((error) => {
1589
+ throw new InvalidQueryStatusError(queryId, error instanceof Error ? error.message : 'query-not-found response was not valid JSON');
1590
+ });
1591
+ if (!isQueryNotFoundResponse(body, queryId)) {
1592
+ throw new InvalidQueryStatusError(queryId, 'query-not-found response fields were inconsistent');
1593
+ }
1594
+ throw new QueryNotRetainedError(queryId, `query ${queryId} is not retained`);
1595
+ }
1596
+ if (!response.ok)
1597
+ throw new KitError(`query status failed with HTTP ${response.status}`);
1598
+ const body = await responseJsonStrict(response).catch((error) => {
1599
+ throw new InvalidQueryStatusError(queryId, error instanceof Error ? error.message : 'response was not valid JSON');
1600
+ });
1601
+ if (!isRemoteQueryStatus(body, queryId)) {
1602
+ throw new InvalidQueryStatusError(queryId, 'response fields were inconsistent');
1603
+ }
1604
+ return body;
1605
+ }
1606
+ async resolveLostQueryOutcome(queryId, cause, initialStatus) {
1607
+ const deadline = Date.now() + SQL_RECOVERY_WINDOW_MS;
1608
+ let status = initialStatus ?? await this.queryStatusForRecovery(queryId, deadline);
1609
+ if (!status || !recoveryStatusIsDecisive(status)) {
1610
+ await this.cancelSqlForRecovery(queryId, deadline);
1611
+ }
1612
+ while (Date.now() < deadline) {
1613
+ if (status && recoveryStatusIsDecisive(status))
1614
+ break;
1615
+ status = await this.queryStatusForRecovery(queryId, deadline) ?? status;
1616
+ if (status && recoveryStatusIsDecisive(status))
1617
+ break;
1618
+ const remaining = deadline - Date.now();
1619
+ if (remaining > 0) {
1620
+ await new Promise((resolve) => setTimeout(resolve, Math.min(SQL_RECOVERY_POLL_INTERVAL_MS, remaining)));
1621
+ }
1622
+ }
1623
+ if (!status || !recoveryStatusIsDecisive(status)) {
1624
+ throw new QueryOutcomeUnknownError(queryId, cause instanceof Error ? cause.message : 'SQL transport failed before terminal status was retained');
1625
+ }
1626
+ const outcome = status?.outcome;
1627
+ const message = cause instanceof Error ? cause.message : 'SQL response was lost';
1628
+ const committed = mergedCommitState(outcome?.committed, status.committed);
1629
+ const metadata = {
1630
+ cancelOutcome: status.cancel_outcome ?? undefined,
1631
+ cancellationReason: status.cancellation_reason,
1632
+ retryable: status.retryable,
1633
+ serverState: status.server_state || status.state
1634
+ };
1635
+ if (committed === null) {
1636
+ throw new QueryOutcomeUnknownError(queryId, message, metadata);
1637
+ }
1638
+ const durableOutcome = {
1639
+ committed,
1640
+ committedStatements: maxKnown(outcome?.committed_statements, status.committed_statements),
1641
+ lastCommitEpoch: remoteEpoch(outcome?.last_commit_epoch_text ?? status.last_commit_epoch_text, outcome?.last_commit_epoch ?? status.last_commit_epoch),
1642
+ firstCommitStatementIndex: outcome?.first_commit_statement_index
1643
+ ?? status.first_commit_statement_index ?? undefined,
1644
+ lastCommitStatementIndex: outcome?.last_commit_statement_index
1645
+ ?? status.last_commit_statement_index ?? undefined,
1646
+ completedStatements: outcome?.completed_statements ?? status.completed_statements ?? undefined,
1647
+ statementIndex: outcome?.statement_index ?? status.statement_index ?? undefined
1648
+ };
1649
+ switch (status.terminal_error?.code) {
1650
+ case 'QUERY_CANCELLED':
1651
+ case 'QUERY_CANCELLED_AFTER_COMMIT':
1652
+ throw new QueryCancelledError(queryId, message, durableOutcome, metadata);
1653
+ case 'DEADLINE_EXCEEDED':
1654
+ case 'DEADLINE_AFTER_COMMIT':
1655
+ throw new QueryTimeoutError(queryId, message, durableOutcome, metadata);
1656
+ case 'RESULT_LIMIT_EXCEEDED':
1657
+ throw new ResultLimitExceededError(queryId, message, durableOutcome, metadata);
1658
+ case 'SERIALIZATION_FAILED':
1659
+ case 'SERIALIZATION_FAILED_AFTER_COMMIT':
1660
+ throw new SerializationError(queryId, message, durableOutcome, metadata);
1661
+ default:
1662
+ if (durableOutcome.committed) {
1663
+ throw new CommitOutcomeError(queryId, message, durableOutcome, metadata);
1664
+ }
1665
+ if (status.state === 'completed') {
1666
+ throw new SerializationError(queryId, message, durableOutcome, metadata);
1667
+ }
1668
+ throw new QueryExecutionError(queryId, status.terminal_error?.code ?? 'QUERY_FAILED', message, durableOutcome, metadata);
1669
+ }
1670
+ }
1671
+ recoveryTimeout(deadline) {
1672
+ const remaining = deadline - Date.now();
1673
+ return remaining > 0
1674
+ ? Math.min(SQL_RECOVERY_REQUEST_TIMEOUT_MS, remaining)
1675
+ : undefined;
1676
+ }
1677
+ async queryStatusForRecovery(queryId, deadline) {
1678
+ const timeout = this.recoveryTimeout(deadline);
1679
+ if (timeout === undefined)
1680
+ return undefined;
1681
+ return this.rawQueryStatus(queryId, timeout).catch(() => undefined);
1682
+ }
1683
+ async cancelSqlForRecovery(queryId, deadline) {
1684
+ const timeout = this.recoveryTimeout(deadline);
1685
+ if (timeout === undefined)
1686
+ return;
1687
+ const response = await this.recoveryRequest(`/queries/${queryId}/cancel`, { method: 'POST' }, timeout).catch(() => undefined);
1688
+ if (response && [200, 202, 404, 409].includes(response.status)) {
1689
+ const body = await responseJsonStrict(response).catch(() => undefined);
1690
+ if (body !== undefined) {
1691
+ try {
1692
+ validateCancelResponse(body, queryId, response.status);
1693
+ }
1694
+ catch {
1695
+ // Recovery remains outcome-unknown when control metadata is malformed.
1696
+ }
1697
+ }
1698
+ }
1699
+ }
1700
+ async executeSql(sql, queryId, timeoutMs, maxOutputRows, maxOutputBytes, signal) {
1701
+ const response = await this.request('/sql', {
106
1702
  method: 'POST',
107
1703
  headers: { 'content-type': 'application/json' },
108
- body: JSON.stringify({ sql, format: 'arrow', query_id: queryId, timeout_ms: timeoutMs }),
1704
+ body: JSON.stringify({
1705
+ sql,
1706
+ format: 'arrow',
1707
+ query_id: queryId,
1708
+ timeout_ms: timeoutMs,
1709
+ max_output_rows: maxOutputRows,
1710
+ max_output_bytes: maxOutputBytes
1711
+ }),
109
1712
  signal
110
1713
  });
111
1714
  if (!response.ok) {
112
- throw await remoteSqlError(response, queryId ?? 'unknown');
1715
+ const error = await remoteSqlError(response, queryId);
1716
+ if (error instanceof QueryOutcomeUnknownError) {
1717
+ return this.resolveLostQueryOutcome(queryId, error);
1718
+ }
1719
+ throw error;
1720
+ }
1721
+ if (response.headers.get('x-mongreldb-query-id') !== queryId) {
1722
+ return this.resolveLostQueryOutcome(queryId, new Error('SQL response x-mongreldb-query-id does not match the request'));
113
1723
  }
114
- const bytes = new Uint8Array(await response.arrayBuffer());
1724
+ const bytes = await responseBytesBounded(response, Math.min(maxOutputBytes ?? MAX_PAGE_JSON_RESPONSE_BYTES, MAX_PAGE_JSON_RESPONSE_BYTES));
115
1725
  if (bytes.length === 0) {
116
1726
  return tableFromJSON([]);
117
1727
  }
118
1728
  return tableFromIPC(bytes);
119
1729
  }
120
- async requireSqlCancellation() {
121
- this.capabilities ??= fetch(`${this.url}/capabilities`).then(async (response) => {
122
- if (response.status === 404)
123
- return null;
124
- if (!response.ok)
125
- throw new KitError(`capability request failed with HTTP ${response.status}`);
126
- const body = await response.json();
127
- return body.sql_cancellation ?? null;
1730
+ async loadCapabilities() {
1731
+ const response = await this.request('/capabilities');
1732
+ if (response.status === 404)
1733
+ return null;
1734
+ if (!response.ok)
1735
+ throw new KitError(`capability request failed with HTTP ${response.status}`);
1736
+ const capabilities = await responseJsonStrict(response).catch(() => {
1737
+ throw new KitError('capability response was not valid bounded JSON', 'REMOTE_PROTOCOL');
128
1738
  });
129
- const capability = await this.capabilities;
130
- if (capability?.version !== 1 || !capability.client_query_ids || !capability.cancel_endpoint) {
131
- throw new KitUnsupportedError('server does not support SQL cancellation capability version 1');
1739
+ if (!isRemoteCapabilities(capabilities)) {
1740
+ throw new KitError('capability response had unknown or invalid fields', 'REMOTE_PROTOCOL');
1741
+ }
1742
+ return capabilities;
1743
+ }
1744
+ validateSqlCancellationCapability(capability) {
1745
+ if (capability?.version !== 2 || !capability.client_query_ids || !capability.cancel_endpoint
1746
+ || !capability.query_status || !capability.pre_registration_cancel) {
1747
+ throw new CapabilityUnsupportedError('server does not support SQL cancellation capability version 2');
1748
+ }
1749
+ return capability;
1750
+ }
1751
+ validateSqlIdempotencyCapability(capability) {
1752
+ if (capability?.version !== 1
1753
+ || !capability.durable_pre_execution_intent
1754
+ || !capability.replay_committed_receipt
1755
+ || !capability.indeterminate_never_reexecutes) {
1756
+ throw new CapabilityUnsupportedError('server does not support durable SQL idempotency capability version 1');
132
1757
  }
133
1758
  return capability;
134
1759
  }
1760
+ async requireSqlCancellation() {
1761
+ this.capabilities ??= this.loadCapabilities();
1762
+ const capability = (await this.capabilities)?.sql_cancellation;
1763
+ return this.validateSqlCancellationCapability(capability);
1764
+ }
1765
+ async requireSqlPagination() {
1766
+ await this.requireSqlCancellation();
1767
+ const capability = (await this.capabilities)?.sql_pagination;
1768
+ if (capability?.version !== 1
1769
+ || capability.continuation_endpoint !== '/sql/continue'
1770
+ || !capability.retained_snapshot
1771
+ || !capability.projection_required
1772
+ || !capability.byte_and_token_hints) {
1773
+ throw new CapabilityUnsupportedError('server does not support SQL pagination capability version 1');
1774
+ }
1775
+ return capability;
1776
+ }
1777
+ async requireSqlIdempotency() {
1778
+ await this.requireSqlCancellation();
1779
+ const capability = (await this.capabilities)?.sql_idempotency;
1780
+ return this.validateSqlIdempotencyCapability(capability);
1781
+ }
1782
+ async requireFreshSqlIdempotency() {
1783
+ const capabilities = await this.loadCapabilities();
1784
+ this.validateSqlCancellationCapability(capabilities?.sql_cancellation);
1785
+ this.validateSqlIdempotencyCapability(capabilities?.sql_idempotency);
1786
+ }
135
1787
  /** Flush/commit `table` on the server; returns the new epoch. */
136
1788
  commit(table) {
137
- return this.inner.commit(table);
1789
+ try {
1790
+ return this.inner.commit(table);
1791
+ }
1792
+ catch (error) {
1793
+ throw nativeRemoteWriteError(error);
1794
+ }
138
1795
  }
139
1796
  createProcedure(spec) {
140
- return JSON.parse(this.inner.createProcedure({ json: procedureJson(spec) }));
1797
+ try {
1798
+ return parseRemoteWriteJson(this.inner.createProcedure({ json: procedureJson(spec) }), 'procedure creation');
1799
+ }
1800
+ catch (error) {
1801
+ throw nativeRemoteWriteError(error);
1802
+ }
141
1803
  }
142
1804
  dropProcedure(name) {
143
- this.inner.dropProcedure(name);
1805
+ try {
1806
+ this.inner.dropProcedure(name);
1807
+ }
1808
+ catch (error) {
1809
+ throw nativeRemoteWriteError(error);
1810
+ }
144
1811
  }
145
1812
  callProcedure(name, opts = {}) {
146
- return JSON.parse(this.inner.callProcedure(name, {
147
- argsJson: JSON.stringify(opts.args ?? {}),
148
- idempotencyKey: opts.idempotencyKey
149
- }));
1813
+ try {
1814
+ return parseProcedureCallResponse(this.inner.callProcedure(name, {
1815
+ argsJson: JSON.stringify(opts.args ?? {}),
1816
+ idempotencyKey: opts.idempotencyKey
1817
+ }));
1818
+ }
1819
+ catch (error) {
1820
+ throw nativeRemoteWriteError(error);
1821
+ }
150
1822
  }
151
1823
  createTrigger(spec) {
152
- return JSON.parse(this.inner.createTrigger({ json: triggerJson(spec) }));
1824
+ try {
1825
+ return parseRemoteWriteJson(this.inner.createTrigger({ json: triggerJson(spec) }), 'trigger creation');
1826
+ }
1827
+ catch (error) {
1828
+ throw nativeRemoteWriteError(error);
1829
+ }
153
1830
  }
154
1831
  replaceTrigger(name, spec) {
155
- return JSON.parse(this.inner.replaceTrigger(name, { json: triggerJson(spec) }));
1832
+ try {
1833
+ return parseRemoteWriteJson(this.inner.replaceTrigger(name, { json: triggerJson(spec) }), 'trigger replacement');
1834
+ }
1835
+ catch (error) {
1836
+ throw nativeRemoteWriteError(error);
1837
+ }
156
1838
  }
157
1839
  dropTrigger(name) {
158
- this.inner.dropTrigger(name);
1840
+ try {
1841
+ this.inner.dropTrigger(name);
1842
+ }
1843
+ catch (error) {
1844
+ throw nativeRemoteWriteError(error);
1845
+ }
159
1846
  }
160
1847
  triggers() {
161
- return JSON.parse(this.inner.triggers());
1848
+ return parseJsonStrict(this.inner.triggers());
162
1849
  }
163
1850
  trigger(name) {
164
- return JSON.parse(this.inner.trigger(name));
1851
+ return parseJsonStrict(this.inner.trigger(name));
165
1852
  }
166
1853
  createVirtualTable(spec) {
167
1854
  return this.sql(createVirtualTableSql(spec));
@@ -180,26 +1867,286 @@ export class RemoteDatabase {
180
1867
  return this.inner.compactTable(table);
181
1868
  }
182
1869
  }
1870
+ function isRemoteCapabilities(value) {
1871
+ if (value === null || typeof value !== 'object')
1872
+ return false;
1873
+ const body = value;
1874
+ if (!hasOnlyKeys(body, ['sql_cancellation', 'sql_idempotency', 'sql_pagination']))
1875
+ return false;
1876
+ const cancellation = body.sql_cancellation;
1877
+ if (cancellation !== undefined && (cancellation === null || typeof cancellation !== 'object'
1878
+ || !hasOnlyKeys(cancellation, [
1879
+ 'version', 'client_query_ids', 'cancel_endpoint', 'query_status',
1880
+ 'stream_disconnect_cancels', 'pre_registration_cancel'
1881
+ ])
1882
+ || !isNonNegativeInteger(cancellation.version) || cancellation.version > 255
1883
+ || ['client_query_ids', 'cancel_endpoint', 'query_status',
1884
+ 'stream_disconnect_cancels', 'pre_registration_cancel']
1885
+ .some((field) => typeof cancellation[field] !== 'boolean')))
1886
+ return false;
1887
+ const idempotency = body.sql_idempotency;
1888
+ if (idempotency !== undefined && (idempotency === null || typeof idempotency !== 'object'
1889
+ || !hasOnlyKeys(idempotency, [
1890
+ 'version', 'durable_pre_execution_intent', 'replay_committed_receipt',
1891
+ 'indeterminate_never_reexecutes'
1892
+ ])
1893
+ || !isNonNegativeInteger(idempotency.version) || idempotency.version > 255
1894
+ || ['durable_pre_execution_intent', 'replay_committed_receipt',
1895
+ 'indeterminate_never_reexecutes']
1896
+ .some((field) => typeof idempotency[field] !== 'boolean')))
1897
+ return false;
1898
+ const pagination = body.sql_pagination;
1899
+ return pagination === undefined || pagination !== null && typeof pagination === 'object'
1900
+ && hasOnlyKeys(pagination, [
1901
+ 'version', 'continuation_endpoint', 'retained_snapshot', 'projection_required',
1902
+ 'byte_and_token_hints'
1903
+ ])
1904
+ && isNonNegativeInteger(pagination.version) && pagination.version <= 255
1905
+ && typeof pagination.continuation_endpoint === 'string'
1906
+ && ['retained_snapshot', 'projection_required', 'byte_and_token_hints']
1907
+ .every((field) => typeof pagination[field] === 'boolean');
1908
+ }
1909
+ function isRemoteSqlErrorEnvelope(value, queryId) {
1910
+ if (value === null || typeof value !== 'object')
1911
+ return false;
1912
+ const body = value;
1913
+ if (body.outcome === null || typeof body.outcome !== 'object'
1914
+ || body.error === null || typeof body.error !== 'object')
1915
+ return false;
1916
+ const outcome = body.outcome;
1917
+ const error = body.error;
1918
+ if (!hasOnlyKeys(body, [
1919
+ 'query_id', 'status', 'terminal_state', 'committed', 'committed_statements',
1920
+ 'last_commit_epoch', 'last_commit_epoch_text', 'first_commit_statement_index',
1921
+ 'last_commit_statement_index', 'completed_statements', 'statement_index',
1922
+ 'cancel_outcome', 'cancellation_reason', 'retryable', 'server_state', 'outcome',
1923
+ 'error', 'max_rows', 'max_bytes'
1924
+ ]) || !hasOnlyKeys(outcome, [
1925
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
1926
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
1927
+ 'statement_index', 'serialization'
1928
+ ]) || !hasOnlyKeys(error, [
1929
+ 'code', 'message', 'query_id', 'committed', 'retryable', 'max_rows', 'max_bytes'
1930
+ ]) || !hasAllKeys(outcome, [
1931
+ 'committed', 'committed_statements', 'last_commit_epoch', 'last_commit_epoch_text',
1932
+ 'first_commit_statement_index', 'last_commit_statement_index', 'completed_statements',
1933
+ 'statement_index', 'serialization'
1934
+ ]))
1935
+ return false;
1936
+ const code = error.code;
1937
+ const status = body.status;
1938
+ if (body.query_id !== queryId || error.query_id !== queryId
1939
+ || typeof code !== 'string' || code.trim().length === 0
1940
+ || typeof error.message !== 'string' || error.message.trim().length === 0
1941
+ || typeof status !== 'string' || body.terminal_state !== status
1942
+ || !['not_started', 'in_progress', 'succeeded', 'failed', 'unknown']
1943
+ .includes(outcome.serialization)
1944
+ || typeof body.retryable !== 'boolean' || error.retryable !== body.retryable
1945
+ || ![null, true, false].includes(body.committed)
1946
+ || body.committed !== outcome.committed || body.committed !== error.committed)
1947
+ return false;
1948
+ const names = ['committed_statements', 'first_commit_statement_index',
1949
+ 'last_commit_statement_index', 'completed_statements', 'statement_index'];
1950
+ if (names.some((name) => !isOptionalNonNegativeInteger(body[name])
1951
+ || !sameOptional(body[name], outcome[name])))
1952
+ return false;
1953
+ for (const name of ['max_rows', 'max_bytes']) {
1954
+ const topLimit = body[name];
1955
+ const errorLimit = error[name];
1956
+ if (topLimit != null && (!isNonNegativeInteger(topLimit) || topLimit === 0)
1957
+ || errorLimit != null && (!isNonNegativeInteger(errorLimit) || errorLimit === 0)
1958
+ || topLimit != null && errorLimit != null && topLimit !== errorLimit)
1959
+ return false;
1960
+ }
1961
+ if (!isOptionalEpoch(body.last_commit_epoch, body.last_commit_epoch_text)
1962
+ || !isOptionalEpoch(outcome.last_commit_epoch, outcome.last_commit_epoch_text))
1963
+ return false;
1964
+ const topEpoch = receiptEpoch(body.last_commit_epoch, body.last_commit_epoch_text);
1965
+ const outcomeEpoch = receiptEpoch(outcome.last_commit_epoch, outcome.last_commit_epoch_text);
1966
+ if (topEpoch !== outcomeEpoch)
1967
+ return false;
1968
+ const committed = body.committed;
1969
+ const committedStatements = body.committed_statements;
1970
+ const first = body.first_commit_statement_index;
1971
+ const last = body.last_commit_statement_index;
1972
+ const completed = body.completed_statements;
1973
+ const statement = body.statement_index;
1974
+ const unknown = code === 'QUERY_OUTCOME_UNKNOWN';
1975
+ if (committed === true) {
1976
+ if (unknown || !COMMITTED_QUERY_STATUSES.has(status)
1977
+ || committedStatements == null || committedStatements === 0
1978
+ || topEpoch === undefined || body.last_commit_epoch_text == null
1979
+ || outcome.last_commit_epoch_text == null || first == null || last == null
1980
+ || completed == null || statement == null)
1981
+ return false;
1982
+ }
1983
+ else if (committed === false) {
1984
+ if (unknown || !['failed_before_commit', 'cancelled_before_commit',
1985
+ 'deadline_before_commit', 'cancelled_before_start'].includes(status)
1986
+ || committedStatements !== 0 || topEpoch !== undefined
1987
+ || first != null || last != null || completed == null || statement == null)
1988
+ return false;
1989
+ }
1990
+ else if (!unknown || status !== 'outcome_unknown'
1991
+ || names.some((name) => body[name] != null) || topEpoch !== undefined
1992
+ || body.retryable !== false)
1993
+ return false;
1994
+ if (first != null && last != null && committedStatements != null && statement != null
1995
+ && (first > last || committedStatements > last - first + 1 || last > statement))
1996
+ return false;
1997
+ if (completed != null && statement != null
1998
+ && (statement > completed || completed > statement + 1))
1999
+ return false;
2000
+ const expectedRetryable = ['QUERY_REGISTRY_FULL', 'IDEMPOTENCY_STORE_FULL',
2001
+ 'IDEMPOTENCY_STORE_UNAVAILABLE'].includes(code);
2002
+ if (body.retryable !== expectedRetryable)
2003
+ return false;
2004
+ const codeMatchesStatus = {
2005
+ QUERY_OUTCOME_UNKNOWN: status === 'outcome_unknown',
2006
+ QUERY_CANCELLED_AFTER_COMMIT: status === 'cancelled_after_commit' && committed,
2007
+ DEADLINE_AFTER_COMMIT: status === 'deadline_after_commit' && committed,
2008
+ QUERY_CANCELLED: ['cancelled_before_commit', 'cancelled_before_start'].includes(status),
2009
+ DEADLINE_EXCEEDED: status === 'deadline_before_commit',
2010
+ COMMIT_OUTCOME: committed === true,
2011
+ SERIALIZATION_FAILED_AFTER_COMMIT: committed === true
2012
+ }[code] ?? true;
2013
+ const statusMatchesCode = {
2014
+ outcome_unknown: code === 'QUERY_OUTCOME_UNKNOWN',
2015
+ cancelled_after_commit: code === 'QUERY_CANCELLED_AFTER_COMMIT',
2016
+ deadline_after_commit: code === 'DEADLINE_AFTER_COMMIT',
2017
+ cancelled_before_commit: code === 'QUERY_CANCELLED',
2018
+ cancelled_before_start: code === 'QUERY_CANCELLED',
2019
+ deadline_before_commit: code === 'DEADLINE_EXCEEDED'
2020
+ }[status] ?? true;
2021
+ return codeMatchesStatus && statusMatchesCode;
2022
+ }
2023
+ function isSqlCursorErrorEnvelope(value) {
2024
+ if (value === null || typeof value !== 'object')
2025
+ return false;
2026
+ const body = value;
2027
+ const outcome = body.outcome;
2028
+ const error = body.error;
2029
+ const countFields = [
2030
+ 'committed_statements', 'first_commit_statement_index',
2031
+ 'last_commit_statement_index', 'completed_statements', 'statement_index'
2032
+ ];
2033
+ return hasOnlyKeys(body, [
2034
+ 'status', 'terminal_state', 'server_state', 'committed', ...countFields,
2035
+ 'last_commit_epoch', 'last_commit_epoch_text', 'cancel_outcome',
2036
+ 'cancellation_reason', 'retryable', 'outcome', 'error'
2037
+ ]) && hasAllKeys(body, [
2038
+ 'status', 'terminal_state', 'server_state', 'committed', ...countFields,
2039
+ 'last_commit_epoch', 'last_commit_epoch_text', 'cancel_outcome',
2040
+ 'cancellation_reason', 'retryable', 'outcome', 'error'
2041
+ ])
2042
+ && outcome !== null && typeof outcome === 'object'
2043
+ && hasOnlyKeys(outcome, [
2044
+ 'committed', ...countFields, 'last_commit_epoch', 'last_commit_epoch_text',
2045
+ 'serialization'
2046
+ ]) && hasAllKeys(outcome, [
2047
+ 'committed', ...countFields, 'last_commit_epoch', 'last_commit_epoch_text',
2048
+ 'serialization'
2049
+ ])
2050
+ && error !== null && typeof error === 'object'
2051
+ && hasOnlyKeys(error, ['code', 'message', 'committed', 'retryable'])
2052
+ && hasAllKeys(error, ['code', 'message', 'committed', 'retryable'])
2053
+ && body.status === 'failed_before_commit'
2054
+ && body.terminal_state === 'failed_before_commit' && body.server_state === 'failed'
2055
+ && body.committed === false && outcome.committed === false
2056
+ && body.committed_statements === 0 && outcome.committed_statements === 0
2057
+ && body.last_commit_epoch === null && outcome.last_commit_epoch === null
2058
+ && body.last_commit_epoch_text === null && outcome.last_commit_epoch_text === null
2059
+ && body.first_commit_statement_index === null
2060
+ && outcome.first_commit_statement_index === null
2061
+ && body.last_commit_statement_index === null
2062
+ && outcome.last_commit_statement_index === null
2063
+ && body.completed_statements === 0 && outcome.completed_statements === 0
2064
+ && body.statement_index === 0 && outcome.statement_index === 0
2065
+ && body.cancel_outcome === null && body.cancellation_reason === null
2066
+ && body.retryable === false && outcome.serialization === 'not_started'
2067
+ && typeof error.code === 'string' && error.code.length > 0
2068
+ && typeof error.message === 'string' && error.message.length > 0
2069
+ && error.committed === false && error.retryable === false;
2070
+ }
183
2071
  async function remoteSqlError(response, fallbackQueryId) {
184
2072
  let body = {};
185
2073
  try {
186
- body = await response.json();
2074
+ const value = await responseJsonStrict(response);
2075
+ if (fallbackQueryId === 'unknown' && isSqlCursorErrorEnvelope(value)) {
2076
+ body = value;
2077
+ }
2078
+ else {
2079
+ const candidateQueryId = value !== null && typeof value === 'object'
2080
+ && typeof value.query_id === 'string'
2081
+ && /^[0-9a-f]{32}$/u.test(value.query_id)
2082
+ ? value.query_id
2083
+ : undefined;
2084
+ const expectedQueryId = fallbackQueryId === 'unknown' ? candidateQueryId : fallbackQueryId;
2085
+ if (expectedQueryId === undefined || !isRemoteSqlErrorEnvelope(value, expectedQueryId)) {
2086
+ return fallbackQueryId === 'unknown'
2087
+ ? new SerializationError('unknown', `SQL error response was malformed (HTTP ${response.status})`, { committed: false })
2088
+ : new QueryOutcomeUnknownError(fallbackQueryId, `SQL error response was malformed (HTTP ${response.status})`, { retryable: false, serverState: 'invalid_outcome' });
2089
+ }
2090
+ body = value;
2091
+ }
187
2092
  }
188
2093
  catch {
189
- return new KitError(`SQL request failed with HTTP ${response.status}`);
2094
+ return fallbackQueryId === 'unknown'
2095
+ ? new SerializationError('unknown', `SQL error response was not valid JSON (HTTP ${response.status})`, { committed: false })
2096
+ : new QueryOutcomeUnknownError(fallbackQueryId, `SQL error response was not valid JSON (HTTP ${response.status})`);
190
2097
  }
191
2098
  const queryId = body.error?.query_id ?? fallbackQueryId;
192
2099
  const message = body.error?.message;
193
- switch (body.error?.code) {
2100
+ const outcome = {
2101
+ committed: mergedCommitState(body.outcome?.committed, body.committed),
2102
+ committedStatements: maxKnown(body.outcome?.committed_statements, body.committed_statements),
2103
+ lastCommitEpoch: remoteEpoch(body.outcome?.last_commit_epoch_text ?? body.last_commit_epoch_text, body.outcome?.last_commit_epoch ?? body.last_commit_epoch),
2104
+ firstCommitStatementIndex: body.outcome?.first_commit_statement_index
2105
+ ?? body.first_commit_statement_index,
2106
+ lastCommitStatementIndex: body.outcome?.last_commit_statement_index
2107
+ ?? body.last_commit_statement_index,
2108
+ completedStatements: maxKnown(body.outcome?.completed_statements, body.completed_statements),
2109
+ statementIndex: maxKnown(body.outcome?.statement_index, body.statement_index)
2110
+ };
2111
+ const metadata = {
2112
+ cancelOutcome: body.cancel_outcome,
2113
+ cancellationReason: body.cancellation_reason,
2114
+ retryable: body.retryable,
2115
+ serverState: body.server_state
2116
+ };
2117
+ const serverCode = body.error?.code;
2118
+ switch (serverCode) {
194
2119
  case 'QUERY_CANCELLED':
195
- return new QueryCancelledError(queryId, message);
2120
+ case 'QUERY_CANCELLED_AFTER_COMMIT':
2121
+ return new QueryCancelledError(queryId, message, outcome, metadata);
196
2122
  case 'DEADLINE_EXCEEDED':
197
- return new QueryTimeoutError(queryId, message);
2123
+ case 'DEADLINE_AFTER_COMMIT':
2124
+ return new QueryTimeoutError(queryId, message, outcome, metadata);
198
2125
  case 'QUERY_ID_CONFLICT':
199
2126
  return new QueryIdConflictError(queryId, message);
200
2127
  case 'TRANSACTION_ABORTED':
201
2128
  return new TransactionAbortedError(message);
2129
+ case 'COMMIT_OUTCOME':
2130
+ return new CommitOutcomeError(queryId, message ?? 'SQL committed but response failed', { ...outcome, committed: true }, metadata);
2131
+ case 'SERIALIZATION_FAILED_AFTER_COMMIT':
2132
+ return new SerializationError(queryId, message ?? 'SQL committed but response serialization failed', { ...outcome, committed: true }, metadata);
2133
+ case 'QUERY_OUTCOME_UNKNOWN':
2134
+ return new QueryOutcomeUnknownError(queryId, message, metadata);
2135
+ case 'QUERY_REGISTRY_FULL':
2136
+ case 'CANCEL_TOO_LATE':
2137
+ case 'QUERY_ALREADY_FINISHED':
2138
+ case 'QUERY_NOT_FOUND':
2139
+ return new KitError(message ?? `SQL request failed with HTTP ${response.status}`, serverCode);
2140
+ case 'RESULT_LIMIT_EXCEEDED':
2141
+ return new ResultLimitExceededError(queryId, message ?? 'SQL result limit exceeded', outcome, metadata);
2142
+ case 'SERIALIZATION_FAILED':
2143
+ return new SerializationError(queryId, message ?? 'SQL response serialization failed', outcome, metadata);
2144
+ case 'CAPABILITY_UNSUPPORTED':
2145
+ return new CapabilityUnsupportedError(message ?? 'server capability is unsupported');
202
2146
  default:
2147
+ if (serverCode) {
2148
+ return new RemoteProtocolError(serverCode, response.status, message ?? `SQL request failed with HTTP ${response.status}`, queryId === 'unknown' ? undefined : queryId, outcome, metadata);
2149
+ }
203
2150
  return new KitError(message ?? `SQL request failed with HTTP ${response.status}`);
204
2151
  }
205
2152
  }