@visorcraft/mongreldb-kit 0.55.0 → 0.58.1

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