@rindle/sql-client 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,1116 @@
1
+ import { RindleSqlError, isRindleSqlError, protocolError } from "./errors.js";
2
+ import { newIdempotencyKey, newRequestId } from "./id.js";
3
+ import { decodeSqlValue, encodeStatement } from "./value.js";
4
+ const REQUEST_ATTEMPTS = 3;
5
+ /** How many times `withTransaction` re-drives a commit whose outcome is still unknown. */
6
+ const COMMIT_RESOLUTION_ATTEMPTS = 3;
7
+ const COMMIT_RESOLUTION_BASE_DELAY_MS = 10;
8
+ function isRecord(value) {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+ function requiredString(object, key) {
12
+ const value = object[key];
13
+ if (typeof value !== "string")
14
+ throw protocolError(`server response field ${key} must be a string`);
15
+ return value;
16
+ }
17
+ function nullableString(object, key) {
18
+ const value = object[key];
19
+ if (value !== null && typeof value !== "string") {
20
+ throw protocolError(`server response field ${key} must be a string or null`);
21
+ }
22
+ return value;
23
+ }
24
+ function nullableNumber(object, key) {
25
+ const value = object[key];
26
+ if (value !== null && (typeof value !== "number" || !Number.isFinite(value))) {
27
+ throw protocolError(`server response field ${key} must be a finite number or null`);
28
+ }
29
+ return value;
30
+ }
31
+ function requiredNumber(object, key) {
32
+ const value = object[key];
33
+ if (typeof value !== "number" || !Number.isFinite(value)) {
34
+ throw protocolError(`server response field ${key} must be a finite number`);
35
+ }
36
+ return value;
37
+ }
38
+ function optionalString(object, key) {
39
+ const value = object[key];
40
+ if (value === undefined || value === null)
41
+ return null;
42
+ if (typeof value !== "string")
43
+ throw protocolError(`server response field ${key} must be a string when present`);
44
+ return value;
45
+ }
46
+ function assertMutationIdentity(input) {
47
+ if (!isRecord(input))
48
+ throw new TypeError("mutation input must be an object");
49
+ if (typeof input.clientId !== "string" || input.clientId.length === 0) {
50
+ throw new TypeError("mutation clientId must be a non-empty string");
51
+ }
52
+ if (!Number.isSafeInteger(input.mid) || input.mid < 1) {
53
+ throw new TypeError("mutation mid must be a positive safe integer");
54
+ }
55
+ }
56
+ function normalizeRetryScope(value) {
57
+ return value === "request" || value === "transaction" || value === "closure" || value === "never"
58
+ ? value
59
+ : "never";
60
+ }
61
+ function normalizeTransactionState(value) {
62
+ return value === "open" || value === "closed" || value === "unknown" ? value : undefined;
63
+ }
64
+ function httpError(response, payload, intMode, requestId) {
65
+ const outer = isRecord(payload) ? payload : undefined;
66
+ const body = outer && isRecord(outer.error) ? outer.error : outer;
67
+ const fallbackMessage = `Rindle SQL request failed with HTTP ${response.status}`;
68
+ const message = body && typeof body.message === "string"
69
+ ? body.message
70
+ : outer && typeof outer.error === "string"
71
+ ? outer.error
72
+ : fallbackMessage;
73
+ const code = body && typeof body.code === "string" ? body.code : `HTTP_${response.status}`;
74
+ const sqliteCodeRaw = body?.sqlite_code ?? body?.sqliteCode;
75
+ const sqliteCode = typeof sqliteCodeRaw === "number" ? sqliteCodeRaw : undefined;
76
+ const retryScopeRaw = body?.retry_scope ?? body?.retryScope;
77
+ const inferredRetryScope = response.status >= 500 ? "request" : "never";
78
+ const retryScope = retryScopeRaw === undefined ? inferredRetryScope : normalizeRetryScope(retryScopeRaw);
79
+ const transactionStateRaw = body?.transaction_state ?? body?.transactionState;
80
+ const statementIndexRaw = body?.statement_index ?? body?.statementIndex;
81
+ const hasStatementIndex = statementIndexRaw !== undefined;
82
+ let partialResults;
83
+ const partialResultsRaw = body?.partial_results ?? body?.partialResults;
84
+ const hasPartialResults = partialResultsRaw !== undefined;
85
+ if (hasStatementIndex !== hasPartialResults) {
86
+ throw protocolError("server error statement_index and partial_results must occur together");
87
+ }
88
+ let statementIndex;
89
+ if (hasStatementIndex) {
90
+ if (typeof statementIndexRaw !== "number" || !Number.isSafeInteger(statementIndexRaw) || statementIndexRaw < 0) {
91
+ throw protocolError("server error statement_index must be a non-negative safe integer");
92
+ }
93
+ if (!Array.isArray(partialResultsRaw) || partialResultsRaw.length !== statementIndexRaw) {
94
+ throw protocolError("server error partial_results length must equal statement_index");
95
+ }
96
+ statementIndex = statementIndexRaw;
97
+ partialResults = partialResultsRaw.map((result) => decodeStatementResult(result, intMode));
98
+ }
99
+ return new RindleSqlError({
100
+ code,
101
+ message,
102
+ sqliteCode,
103
+ retryScope,
104
+ transactionState: normalizeTransactionState(transactionStateRaw),
105
+ status: response.status,
106
+ // This is the logical request identity minted by this transport. A proxy's X-Request-Id is a
107
+ // different, per-hop concept; a missing/malformed echo must not replace our stable identity.
108
+ requestId,
109
+ statementIndex,
110
+ partialResults,
111
+ });
112
+ }
113
+ function replaceRetryScope(error, retryScope) {
114
+ return new RindleSqlError({
115
+ code: error.code,
116
+ message: error.message,
117
+ sqliteCode: error.sqliteCode,
118
+ retryScope,
119
+ transactionState: error.transactionState,
120
+ status: error.status,
121
+ requestId: error.requestId,
122
+ statementIndex: error.statementIndex,
123
+ partialResults: error.partialResults,
124
+ cause: error,
125
+ });
126
+ }
127
+ function isAbort(error, signal) {
128
+ return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
129
+ }
130
+ function throwAbort(signal) {
131
+ if (signal?.reason !== undefined)
132
+ throw signal.reason;
133
+ if (typeof DOMException !== "undefined")
134
+ throw new DOMException("The operation was aborted", "AbortError");
135
+ const error = new Error("The operation was aborted");
136
+ error.name = "AbortError";
137
+ throw error;
138
+ }
139
+ function delay(ms, signal) {
140
+ if (signal?.aborted)
141
+ throwAbort(signal);
142
+ return new Promise((resolve, reject) => {
143
+ const timer = setTimeout(() => {
144
+ signal?.removeEventListener("abort", abort);
145
+ resolve();
146
+ }, ms);
147
+ const abort = () => {
148
+ clearTimeout(timer);
149
+ signal?.removeEventListener("abort", abort);
150
+ try {
151
+ throwAbort(signal);
152
+ }
153
+ catch (error) {
154
+ reject(error);
155
+ }
156
+ };
157
+ signal?.addEventListener("abort", abort, { once: true });
158
+ });
159
+ }
160
+ function linkedSignal(one, two) {
161
+ if (one === undefined)
162
+ return { signal: two, dispose: () => { } };
163
+ const controller = new AbortController();
164
+ const abortOne = () => controller.abort(one.reason);
165
+ const abortTwo = () => controller.abort(two.reason);
166
+ if (one.aborted)
167
+ abortOne();
168
+ else
169
+ one.addEventListener("abort", abortOne, { once: true });
170
+ if (two.aborted)
171
+ abortTwo();
172
+ else
173
+ two.addEventListener("abort", abortTwo, { once: true });
174
+ return {
175
+ signal: controller.signal,
176
+ dispose: () => {
177
+ one.removeEventListener("abort", abortOne);
178
+ two.removeEventListener("abort", abortTwo);
179
+ },
180
+ };
181
+ }
182
+ function retryAfterMs(response, attempt) {
183
+ const value = response.headers.get("retry-after");
184
+ if (value !== null) {
185
+ const seconds = Number(value);
186
+ if (Number.isFinite(seconds) && seconds >= 0)
187
+ return Math.min(seconds * 1_000, 1_000);
188
+ const date = Date.parse(value);
189
+ if (Number.isFinite(date))
190
+ return Math.max(0, Math.min(date - Date.now(), 1_000));
191
+ }
192
+ return Math.min(20 * 2 ** attempt, 160);
193
+ }
194
+ class Transport {
195
+ baseUrl;
196
+ authToken;
197
+ consistency;
198
+ intMode;
199
+ fetchImpl;
200
+ closeController = new AbortController();
201
+ closed = false;
202
+ constructor(options) {
203
+ if (typeof options.url !== "string" || options.url.trim() === "")
204
+ throw new TypeError("url must be a non-empty string");
205
+ if (typeof options.authToken !== "string" || options.authToken === "") {
206
+ throw new TypeError("authToken must be a non-empty string");
207
+ }
208
+ if (options.consistency !== undefined && !["session", "strong", "eventual"].includes(options.consistency)) {
209
+ throw new TypeError(`unsupported consistency: ${String(options.consistency)}`);
210
+ }
211
+ if (options.intMode !== undefined && !["bigint", "number", "string"].includes(options.intMode)) {
212
+ throw new TypeError(`unsupported intMode: ${String(options.intMode)}`);
213
+ }
214
+ const runtimeFetch = globalThis.fetch?.bind(globalThis);
215
+ this.fetchImpl = options.fetch ?? runtimeFetch ?? (() => Promise.reject(new TypeError("global fetch is unavailable")));
216
+ this.baseUrl = options.url.replace(/\/+$/, "");
217
+ this.authToken = options.authToken;
218
+ this.consistency = options.consistency ?? "session";
219
+ this.intMode = options.intMode ?? "bigint";
220
+ }
221
+ isClosed() {
222
+ return this.closed;
223
+ }
224
+ close() {
225
+ if (this.closed)
226
+ return;
227
+ this.closed = true;
228
+ this.closeController.abort(new RindleSqlError({ code: "CLIENT_CLOSED", message: "SQL client is closed" }));
229
+ }
230
+ async request(path, options = {}) {
231
+ if (this.closed)
232
+ throw new RindleSqlError({ code: "CLIENT_CLOSED", message: "SQL client is closed" });
233
+ const method = options.method ?? "POST";
234
+ const authenticated = options.authenticated ?? true;
235
+ const expectJson = options.expectJson ?? true;
236
+ const encodedBody = options.body === undefined ? undefined : JSON.stringify(options.body);
237
+ const requestId = newRequestId();
238
+ const attempts = options.retrySafe === true ? REQUEST_ATTEMPTS : 1;
239
+ let lastError;
240
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
241
+ const linked = linkedSignal(options.signal, this.closeController.signal);
242
+ try {
243
+ if (linked.signal.aborted)
244
+ throwAbort(linked.signal);
245
+ const headers = {
246
+ Accept: "application/json",
247
+ "Rindle-Request-Id": requestId,
248
+ };
249
+ if (encodedBody !== undefined)
250
+ headers["Content-Type"] = "application/json";
251
+ if (authenticated)
252
+ headers.Authorization = `Bearer ${this.authToken}`;
253
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
254
+ method,
255
+ headers,
256
+ body: encodedBody,
257
+ signal: linked.signal,
258
+ });
259
+ let payload;
260
+ const text = response.status === 204 ? "" : await response.text();
261
+ if (text !== "") {
262
+ try {
263
+ payload = JSON.parse(text);
264
+ }
265
+ catch (cause) {
266
+ if (response.ok)
267
+ throw protocolError("Rindle SQL returned invalid JSON", cause);
268
+ }
269
+ }
270
+ if (response.ok) {
271
+ if (expectJson && payload === undefined)
272
+ throw protocolError("Rindle SQL returned an empty JSON response");
273
+ return payload;
274
+ }
275
+ const error = httpError(response, payload, this.intMode, requestId);
276
+ lastError = error;
277
+ if (attempt + 1 >= attempts || error.retryScope !== "request")
278
+ throw error;
279
+ await delay(retryAfterMs(response, attempt), options.signal);
280
+ }
281
+ catch (error) {
282
+ if (isAbort(error, linked.signal)) {
283
+ if (this.closed)
284
+ throw new RindleSqlError({ code: "CLIENT_CLOSED", message: "SQL client is closed", cause: error });
285
+ throwAbort(options.signal ?? linked.signal);
286
+ }
287
+ if (isRindleSqlError(error)) {
288
+ lastError = error;
289
+ if (attempt + 1 >= attempts) {
290
+ const exhaustedScope = options.exhaustedRequestRetryScope ?? "never";
291
+ throw error.retryScope === "request" && exhaustedScope !== "request"
292
+ ? replaceRetryScope(error, exhaustedScope)
293
+ : error;
294
+ }
295
+ if (error.retryScope !== "request")
296
+ throw error;
297
+ }
298
+ else {
299
+ lastError = error;
300
+ if (attempt + 1 >= attempts) {
301
+ throw new RindleSqlError({
302
+ code: "TRANSPORT_ERROR",
303
+ message: error instanceof Error ? error.message : "Rindle SQL transport failed",
304
+ retryScope: options.exhaustedRequestRetryScope ?? "never",
305
+ cause: error,
306
+ });
307
+ }
308
+ }
309
+ await delay(Math.min(20 * 2 ** attempt, 160), options.signal);
310
+ }
311
+ finally {
312
+ linked.dispose();
313
+ }
314
+ }
315
+ throw lastError;
316
+ }
317
+ }
318
+ function decodeStatementResult(value, intMode) {
319
+ if (!isRecord(value))
320
+ throw protocolError("server statement result must be an object");
321
+ if (!Array.isArray(value.columns))
322
+ throw protocolError("server statement result columns must be an array");
323
+ const columns = value.columns.map((column) => {
324
+ if (!isRecord(column) || typeof column.name !== "string" || (column.decltype !== null && typeof column.decltype !== "string")) {
325
+ throw protocolError("server returned a malformed result column");
326
+ }
327
+ return { name: column.name, decltype: column.decltype };
328
+ });
329
+ if (!Array.isArray(value.rows))
330
+ throw protocolError("server statement result rows must be an array");
331
+ const rows = value.rows.map((row) => {
332
+ if (!Array.isArray(row))
333
+ throw protocolError("server returned a malformed result row");
334
+ if (row.length !== columns.length)
335
+ throw protocolError("server returned a result row with the wrong column count");
336
+ return row.map((cell) => decodeSqlValue(cell, intMode));
337
+ });
338
+ return {
339
+ columns,
340
+ rows,
341
+ rowsAffected: requiredNumber(value, "rows_affected"),
342
+ lastInsertRowid: nullableString(value, "last_insert_rowid"),
343
+ rowsRead: nullableNumber(value, "rows_read"),
344
+ rowsWritten: nullableNumber(value, "rows_written"),
345
+ };
346
+ }
347
+ function decodeRouting(value) {
348
+ if (!isRecord(value))
349
+ throw protocolError("server routing metadata must be an object");
350
+ if (value.served_by !== "master" && value.served_by !== "follower") {
351
+ throw protocolError("server routing served_by must be master or follower");
352
+ }
353
+ if (typeof value.fence_fallback !== "boolean")
354
+ throw protocolError("server routing fence_fallback must be boolean");
355
+ return {
356
+ servedBy: value.served_by,
357
+ appliedLagMs: nullableNumber(value, "applied_lag_ms"),
358
+ fenceFallback: value.fence_fallback,
359
+ };
360
+ }
361
+ function decodeExecute(value, intMode) {
362
+ if (!isRecord(value))
363
+ throw protocolError("server execute response must be an object");
364
+ return {
365
+ result: decodeStatementResult(value.result, intMode),
366
+ commitCursor: nullableString(value, "commit_cursor"),
367
+ routing: decodeRouting(value.routing),
368
+ };
369
+ }
370
+ function decodeBatch(value, intMode) {
371
+ if (!isRecord(value) || !Array.isArray(value.results))
372
+ throw protocolError("server batch response must contain results");
373
+ return {
374
+ results: value.results.map((result) => decodeStatementResult(result, intMode)),
375
+ commitCursor: nullableString(value, "commit_cursor"),
376
+ routing: decodeRouting(value.routing),
377
+ };
378
+ }
379
+ function decodeMutationReceipt(value) {
380
+ if (!isRecord(value) || typeof value.applied !== "boolean") {
381
+ throw protocolError("server mutation response must contain applied and lmid");
382
+ }
383
+ const lmid = requiredNumber(value, "lmid");
384
+ if (!Number.isSafeInteger(lmid) || lmid < 0) {
385
+ throw protocolError("server mutation lmid must be a non-negative safe integer");
386
+ }
387
+ return {
388
+ applied: value.applied,
389
+ lmid,
390
+ commitCursor: optionalString(value, "cursor"),
391
+ };
392
+ }
393
+ function decodeMutationRows(value, intMode) {
394
+ if (!isRecord(value) || !Array.isArray(value.cols) || !value.cols.every((column) => typeof column === "string")) {
395
+ throw protocolError("server mutation read response must contain string cols");
396
+ }
397
+ if (!Array.isArray(value.rows))
398
+ throw protocolError("server mutation read response must contain rows");
399
+ const columns = value.cols;
400
+ const rows = value.rows.map((row) => {
401
+ if (!Array.isArray(row) || row.length !== columns.length) {
402
+ throw protocolError("server mutation read row has the wrong column count");
403
+ }
404
+ // The mutation-session wire predates the public SQL tagged-value response and can still carry
405
+ // a raw boolean. All other cells use the public decoder (including future tagged values).
406
+ return row.map((cell) => (typeof cell === "boolean" ? cell : decodeSqlValue(cell, intMode)));
407
+ });
408
+ return { columns, rows };
409
+ }
410
+ function addCursor(body, options, state) {
411
+ const hasOverride = options?.sessionCursor !== undefined;
412
+ const cursor = hasOverride ? options.sessionCursor : state.cursor;
413
+ if (cursor !== null || hasOverride)
414
+ body.session_cursor = cursor;
415
+ }
416
+ function acceptCursor(state, cursor) {
417
+ if (cursor === null)
418
+ return;
419
+ // The current server emits `w:` plus sixteen lowercase hex digits. Responses from concurrent
420
+ // requests can arrive out of order, so do not let an older acknowledgment move that
421
+ // same-format fence backwards. Any other shape remains opaque and authoritative: a future
422
+ // timeline-aware encoding must be allowed to re-anchor the client rather than being compared
423
+ // with this process-local compatibility rule.
424
+ const current = state.cursor;
425
+ const parseCurrentCursor = (value) => {
426
+ if (!/^w:[0-9a-f]{16}$/.test(value))
427
+ return null;
428
+ return BigInt(`0x${value.slice(2)}`);
429
+ };
430
+ if (current !== null) {
431
+ const currentSequence = parseCurrentCursor(current);
432
+ const nextSequence = parseCurrentCursor(cursor);
433
+ if (currentSequence !== null && nextSequence !== null && nextSequence < currentSequence)
434
+ return;
435
+ }
436
+ state.cursor = cursor;
437
+ }
438
+ class Transaction {
439
+ open = true;
440
+ busy = false;
441
+ operationSequence = 0n;
442
+ pendingExecute = null;
443
+ commitOperationId = null;
444
+ transport;
445
+ id;
446
+ cursorState;
447
+ constructor(transport, id, cursorState) {
448
+ this.transport = transport;
449
+ this.id = id;
450
+ this.cursorState = cursorState;
451
+ }
452
+ ensureUsable() {
453
+ if (!this.open) {
454
+ throw new RindleSqlError({
455
+ code: "TRANSACTION_CLOSED",
456
+ message: "transaction is closed",
457
+ retryScope: "never",
458
+ transactionState: "closed",
459
+ });
460
+ }
461
+ if (this.busy) {
462
+ throw new RindleSqlError({
463
+ code: "TRANSACTION_BUSY",
464
+ message: "transaction operations must be awaited serially",
465
+ retryScope: "never",
466
+ transactionState: "open",
467
+ });
468
+ }
469
+ }
470
+ path(suffix) {
471
+ return `/v1/sql/transactions/${encodeURIComponent(this.id)}/${suffix}`;
472
+ }
473
+ /** Transaction-local canonical operation IDs: 1, 2, 3, ... */
474
+ nextOperationId() {
475
+ this.operationSequence += 1n;
476
+ return this.operationSequence.toString();
477
+ }
478
+ async cancelAndRollback(operationId) {
479
+ if (!this.open || this.transport.isClosed())
480
+ return;
481
+ try {
482
+ await this.transport.request(this.path("cancel"), {
483
+ body: { operation_id: operationId },
484
+ retrySafe: true,
485
+ expectJson: false,
486
+ });
487
+ }
488
+ catch {
489
+ // The original abort remains the caller-visible error.
490
+ }
491
+ try {
492
+ await this.transport.request(this.path("rollback"), {
493
+ body: { operation_id: this.nextOperationId() },
494
+ retrySafe: true,
495
+ expectJson: false,
496
+ });
497
+ }
498
+ catch {
499
+ // Best effort cleanup after cancellation.
500
+ }
501
+ this.open = false;
502
+ }
503
+ async executeStatements(statements, options) {
504
+ this.ensureUsable();
505
+ if (statements.length === 0)
506
+ throw new TypeError("transaction batch requires at least one statement");
507
+ if (this.commitOperationId !== null) {
508
+ throw new RindleSqlError({
509
+ code: "TRANSACTION_COMMIT_PENDING",
510
+ message: "the commit outcome is pending; retry commit() or roll back this transaction",
511
+ retryScope: "never",
512
+ transactionState: "unknown",
513
+ });
514
+ }
515
+ const encodedStatements = statements.map((statement) => encodeStatement(statement));
516
+ const requestIdentity = JSON.stringify(encodedStatements);
517
+ const pendingExecute = this.pendingExecute;
518
+ if (pendingExecute !== null && pendingExecute.requestIdentity !== null && pendingExecute.requestIdentity !== requestIdentity) {
519
+ throw new RindleSqlError({
520
+ code: "TRANSACTION_OPERATION_PENDING",
521
+ message: "retry the same transaction statement request before starting a different operation",
522
+ retryScope: "never",
523
+ transactionState: "open",
524
+ });
525
+ }
526
+ const operationId = this.pendingExecute?.operationId ?? this.nextOperationId();
527
+ this.pendingExecute = { operationId, requestIdentity };
528
+ this.busy = true;
529
+ try {
530
+ const payload = await this.transport.request(this.path("execute"), {
531
+ body: { statements: encodedStatements, operation_id: operationId },
532
+ signal: options?.signal,
533
+ retrySafe: true,
534
+ // Unlike an autocommit method, this transaction object can preserve the exact operation
535
+ // identity across a later explicit retry after all internal transport attempts fail.
536
+ exhaustedRequestRetryScope: "request",
537
+ });
538
+ if (!isRecord(payload) || !Array.isArray(payload.results)) {
539
+ throw protocolError("server transaction execute response must contain results");
540
+ }
541
+ const results = payload.results.map((result) => decodeStatementResult(result, this.transport.intMode));
542
+ if (results.length !== encodedStatements.length) {
543
+ throw protocolError("server transaction execute result count must match the statement count");
544
+ }
545
+ this.pendingExecute = null;
546
+ return results;
547
+ }
548
+ catch (error) {
549
+ const requestRetryable = isRindleSqlError(error) && error.retryScope === "request" && error.transactionState !== "closed";
550
+ const rejectedBeforeAdmission = isRindleSqlError(error) &&
551
+ error.retryScope !== "request" &&
552
+ (error.transactionState === "unknown" ||
553
+ (error.transactionState === undefined &&
554
+ (error.status === 400 || error.status === 413) &&
555
+ error.code === `HTTP_${error.status}`));
556
+ if (rejectedBeforeAdmission)
557
+ this.pendingExecute = { operationId, requestIdentity: null };
558
+ else if (!requestRetryable)
559
+ this.pendingExecute = null;
560
+ if (options?.signal?.aborted) {
561
+ this.pendingExecute = null;
562
+ await this.cancelAndRollback(operationId);
563
+ }
564
+ if (isRindleSqlError(error) && error.transactionState === "closed")
565
+ this.open = false;
566
+ throw error;
567
+ }
568
+ finally {
569
+ this.busy = false;
570
+ }
571
+ }
572
+ async execute(statement, options) {
573
+ const results = await this.executeStatements([typeof statement === "string" ? { sql: statement } : statement], options);
574
+ return results[0];
575
+ }
576
+ batch(statements, options) {
577
+ return this.executeStatements(statements, options);
578
+ }
579
+ async commit(options) {
580
+ this.ensureUsable();
581
+ const pendingExecute = this.pendingExecute;
582
+ if (pendingExecute !== null && pendingExecute.requestIdentity !== null) {
583
+ throw new RindleSqlError({
584
+ code: "TRANSACTION_OPERATION_PENDING",
585
+ message: "retry the pending transaction statement request before committing, or roll back",
586
+ retryScope: "never",
587
+ transactionState: "open",
588
+ });
589
+ }
590
+ // A commit may have reached the server even when its response did not reach the caller. Reuse
591
+ // the same terminal operation identity across explicit retries so an open session still sees
592
+ // its next expected sequence and a closed session can replay the retained terminal outcome.
593
+ const reusableOperationId = pendingExecute?.operationId;
594
+ this.pendingExecute = null;
595
+ const operationId = this.commitOperationId ??
596
+ (this.commitOperationId = reusableOperationId ?? this.nextOperationId());
597
+ this.busy = true;
598
+ try {
599
+ const payload = await this.transport.request(this.path("commit"), {
600
+ body: { operation_id: operationId },
601
+ signal: options?.signal,
602
+ retrySafe: true,
603
+ // commitOperationId is retained across an explicit later commit(), so an exhausted
604
+ // request error remains safely request-retryable by callers.
605
+ exhaustedRequestRetryScope: "request",
606
+ });
607
+ if (!isRecord(payload))
608
+ throw protocolError("server transaction commit response must be an object");
609
+ const commitCursor = nullableString(payload, "commit_cursor");
610
+ acceptCursor(this.cursorState, commitCursor);
611
+ this.open = false;
612
+ return { commitCursor };
613
+ }
614
+ catch (error) {
615
+ if (options?.signal?.aborted)
616
+ await this.cancelAndRollback(operationId);
617
+ if (isRindleSqlError(error) &&
618
+ (error.transactionState === "closed" ||
619
+ error.code === "TRANSACTION_CONFLICT" ||
620
+ error.code === "TRANSACTION_EXPIRED" ||
621
+ error.code === "TRANSACTION_CLOSED")) {
622
+ this.open = false;
623
+ }
624
+ throw error;
625
+ }
626
+ finally {
627
+ this.busy = false;
628
+ }
629
+ }
630
+ async rollback(options) {
631
+ if (!this.open)
632
+ return;
633
+ this.ensureUsable();
634
+ // If a commit transport failed with an unknown outcome, its terminal sequence is still the
635
+ // server's next expected operation when the request never arrived. Reusing it lets rollback
636
+ // close that live transaction; if the commit did arrive, rollback of the now-missing handle is
637
+ // already an idempotent success.
638
+ const operationId = this.commitOperationId ?? this.nextOperationId();
639
+ this.busy = true;
640
+ try {
641
+ await this.transport.request(this.path("rollback"), {
642
+ body: { operation_id: operationId },
643
+ signal: options?.signal,
644
+ retrySafe: true,
645
+ expectJson: false,
646
+ exhaustedRequestRetryScope: "request",
647
+ });
648
+ this.open = false;
649
+ }
650
+ catch (error) {
651
+ if (options?.signal?.aborted)
652
+ await this.cancelAndRollback(operationId);
653
+ if (isRindleSqlError(error) && error.transactionState === "closed")
654
+ this.open = false;
655
+ throw error;
656
+ }
657
+ finally {
658
+ this.busy = false;
659
+ }
660
+ }
661
+ }
662
+ class MutationTransaction {
663
+ /** `closed` means the SERVER declared the transaction gone (410, or an explicit closed state), so
664
+ * no rollback is owed. `unknown` means a request failed without an answer (5xx / transport): the
665
+ * writer may still be held, so rollback MUST still go out even though no further work may. Only
666
+ * `closed` suppresses the rollback — collapsing these two is what leaked the server-side writer
667
+ * until its deadline. */
668
+ state = "open";
669
+ busy = false;
670
+ transport;
671
+ id;
672
+ cursorState;
673
+ constructor(transport, id, cursorState) {
674
+ this.transport = transport;
675
+ this.id = id;
676
+ this.cursorState = cursorState;
677
+ }
678
+ ensureUsable() {
679
+ if (this.state !== "open") {
680
+ throw new RindleSqlError({
681
+ code: "TRANSACTION_CLOSED",
682
+ message: this.state === "closed"
683
+ ? "mutation transaction is closed"
684
+ : "mutation transaction is in an unknown state after a failed request",
685
+ retryScope: "never",
686
+ transactionState: this.state === "closed" ? "closed" : "unknown",
687
+ });
688
+ }
689
+ if (this.busy) {
690
+ throw new RindleSqlError({
691
+ code: "TRANSACTION_BUSY",
692
+ message: "mutation transaction operations must be awaited serially",
693
+ retryScope: "never",
694
+ transactionState: "open",
695
+ });
696
+ }
697
+ }
698
+ path(suffix) {
699
+ return `/v1/sql/mutations/transactions/${encodeURIComponent(this.id)}/${suffix}`;
700
+ }
701
+ /** Classify a failed request. Only a server-DECLARED closure retires the rollback obligation; an
702
+ * unanswered request leaves the writer possibly held, which is `unknown`, not `closed`. */
703
+ noteTerminalError(error) {
704
+ if (!isRindleSqlError(error))
705
+ return;
706
+ if (error.status === 410 || error.transactionState === "closed") {
707
+ this.state = "closed";
708
+ return;
709
+ }
710
+ if (error.status !== undefined && error.status >= 500)
711
+ this.state = "unknown";
712
+ }
713
+ async execute(statement, options) {
714
+ await this.batch([typeof statement === "string" ? { sql: statement } : statement], options);
715
+ }
716
+ async batch(statements, options) {
717
+ this.ensureUsable();
718
+ if (statements.length === 0)
719
+ throw new TypeError("mutation transaction batch requires at least one statement");
720
+ this.busy = true;
721
+ try {
722
+ await this.transport.request(this.path("execute"), {
723
+ body: { statements: statements.map((statement) => encodeStatement(statement)) },
724
+ signal: options?.signal,
725
+ retrySafe: false,
726
+ });
727
+ }
728
+ catch (error) {
729
+ this.noteTerminalError(error);
730
+ throw error;
731
+ }
732
+ finally {
733
+ this.busy = false;
734
+ }
735
+ }
736
+ async query(statement, options) {
737
+ this.ensureUsable();
738
+ this.busy = true;
739
+ try {
740
+ return decodeMutationRows(await this.transport.request(this.path("query"), {
741
+ body: { query: encodeStatement(statement) },
742
+ signal: options?.signal,
743
+ retrySafe: false,
744
+ }), this.transport.intMode);
745
+ }
746
+ catch (error) {
747
+ this.noteTerminalError(error);
748
+ throw error;
749
+ }
750
+ finally {
751
+ this.busy = false;
752
+ }
753
+ }
754
+ async commit(options) {
755
+ this.ensureUsable();
756
+ this.busy = true;
757
+ try {
758
+ const receipt = decodeMutationReceipt(await this.transport.request(this.path("commit"), {
759
+ body: {},
760
+ signal: options?.signal,
761
+ retrySafe: false,
762
+ }));
763
+ acceptCursor(this.cursorState, receipt.commitCursor);
764
+ this.state = "closed";
765
+ return receipt;
766
+ }
767
+ catch (error) {
768
+ this.noteTerminalError(error);
769
+ throw error;
770
+ }
771
+ finally {
772
+ this.busy = false;
773
+ }
774
+ }
775
+ async rollback(options) {
776
+ // Only a server-declared closure retires the obligation. An `unknown` transaction still owes a
777
+ // rollback — that is the whole point of the state split — so it deliberately does NOT go through
778
+ // `ensureUsable`, which refuses everything except `open`.
779
+ if (this.state === "closed")
780
+ return;
781
+ if (this.busy) {
782
+ throw new RindleSqlError({
783
+ code: "TRANSACTION_BUSY",
784
+ message: "mutation transaction operations must be awaited serially",
785
+ retryScope: "never",
786
+ transactionState: "open",
787
+ });
788
+ }
789
+ this.busy = true;
790
+ try {
791
+ await this.transport.request(this.path("rollback"), {
792
+ body: {},
793
+ signal: options?.signal,
794
+ retrySafe: true,
795
+ expectJson: false,
796
+ exhaustedRequestRetryScope: "request",
797
+ });
798
+ this.state = "closed";
799
+ }
800
+ catch (error) {
801
+ // A rollback of an already-gone transaction is the outcome the caller wanted: the server
802
+ // answering 410 (or declaring it closed) IS the success case, not a failure to report.
803
+ if (isRindleSqlError(error) && (error.status === 410 || error.transactionState === "closed")) {
804
+ this.state = "closed";
805
+ return;
806
+ }
807
+ throw error;
808
+ }
809
+ finally {
810
+ this.busy = false;
811
+ }
812
+ }
813
+ }
814
+ class Session {
815
+ transport;
816
+ cursorState;
817
+ constructor(transport, cursorState) {
818
+ this.transport = transport;
819
+ this.cursorState = cursorState;
820
+ }
821
+ async execute(statement, options) {
822
+ const body = {
823
+ statement: encodeStatement(statement),
824
+ default_consistency: this.transport.consistency,
825
+ idempotency_key: newIdempotencyKey(),
826
+ };
827
+ if (options?.consistency !== undefined)
828
+ body.consistency = options.consistency;
829
+ addCursor(body, options, this.cursorState);
830
+ const result = decodeExecute(await this.transport.request("/v1/sql/execute", { body, signal: options?.signal, retrySafe: true }), this.transport.intMode);
831
+ acceptCursor(this.cursorState, result.commitCursor);
832
+ return result;
833
+ }
834
+ async batch(statements, options) {
835
+ if (statements.length === 0)
836
+ throw new TypeError("batch requires at least one statement");
837
+ const body = {
838
+ statements: statements.map((statement) => encodeStatement(statement)),
839
+ default_consistency: this.transport.consistency,
840
+ idempotency_key: newIdempotencyKey(),
841
+ };
842
+ if (options?.consistency !== undefined)
843
+ body.consistency = options.consistency;
844
+ addCursor(body, options, this.cursorState);
845
+ const result = decodeBatch(await this.transport.request("/v1/sql/batch", { body, signal: options?.signal, retrySafe: true }), this.transport.intMode);
846
+ if (result.results.length !== statements.length) {
847
+ throw protocolError("server batch result count must match the statement count");
848
+ }
849
+ acceptCursor(this.cursorState, result.commitCursor);
850
+ return result;
851
+ }
852
+ async begin(options) {
853
+ const readOnly = options?.readOnly ?? false;
854
+ const body = {
855
+ read_only: readOnly,
856
+ isolation: options?.isolation ?? "serializable",
857
+ };
858
+ if (readOnly)
859
+ body.default_consistency = this.transport.consistency;
860
+ if (options?.consistency !== undefined)
861
+ body.consistency = options.consistency;
862
+ const hasCursorOverride = options?.sessionCursor !== undefined;
863
+ if (readOnly || hasCursorOverride)
864
+ addCursor(body, options, this.cursorState);
865
+ const payload = await this.transport.request("/v1/sql/transactions", {
866
+ body,
867
+ signal: options?.signal,
868
+ retrySafe: false,
869
+ });
870
+ if (!isRecord(payload))
871
+ throw protocolError("server transaction begin response must be an object");
872
+ return new Transaction(this.transport, requiredString(payload, "transaction_id"), this.cursorState);
873
+ }
874
+ async withTransaction(fn, options) {
875
+ if (typeof fn !== "function")
876
+ throw new TypeError("withTransaction requires a callback");
877
+ const tx = await this.begin(options);
878
+ let value;
879
+ try {
880
+ value = await fn(tx);
881
+ }
882
+ catch (error) {
883
+ // The callback failed, so nothing was ever submitted for commit: rolling back is both
884
+ // truthful and the fastest way to release the server's writer connection.
885
+ try {
886
+ await tx.rollback();
887
+ }
888
+ catch {
889
+ // Preserve the callback error.
890
+ }
891
+ throw error;
892
+ }
893
+ await this.commitToKnownOutcome(tx, options);
894
+ return value;
895
+ }
896
+ /**
897
+ * Drive a transaction's commit to a KNOWN outcome.
898
+ *
899
+ * A commit whose response is lost is outcome-*unknown*, not failed — the server may already hold
900
+ * a durable terminal record for it. `commit()` retains its operation id precisely so repeating
901
+ * the same call reads that record back, and reports `retryScope: "request"` to say the call is
902
+ * safe to repeat. Re-driving it here is the only way an application using this ergonomic surface
903
+ * can reach that machinery.
904
+ *
905
+ * If the outcome is still unknown once the attempts are spent, the error is rethrown WITHOUT a
906
+ * rollback. Rolling back would report a possibly-durable commit as aborted and leave the session
907
+ * fence unadvanced, so the caller's natural response — re-running the mutation — would
908
+ * double-apply it. A genuinely-open transaction is instead reclaimed by the server's session
909
+ * lease, and the retained operation id keeps `tx.commit()` resolvable until then.
910
+ */
911
+ async commitToKnownOutcome(tx, options) {
912
+ for (let attempt = 1;; attempt += 1) {
913
+ try {
914
+ await tx.commit({ signal: options?.signal });
915
+ return;
916
+ }
917
+ catch (error) {
918
+ const outcomeUnknown = isRindleSqlError(error) && error.retryScope === "request";
919
+ if (!outcomeUnknown) {
920
+ // A definite negative (conflict, closed, rejected before admission): nothing committed,
921
+ // so release the transaction before surfacing it.
922
+ try {
923
+ await tx.rollback();
924
+ }
925
+ catch {
926
+ // Preserve the commit error.
927
+ }
928
+ throw error;
929
+ }
930
+ if (attempt >= COMMIT_RESOLUTION_ATTEMPTS || options?.signal?.aborted)
931
+ throw error;
932
+ await delay(COMMIT_RESOLUTION_BASE_DELAY_MS * 2 ** (attempt - 1), options?.signal);
933
+ }
934
+ }
935
+ }
936
+ async withTransactionRetry(fn, options = {}) {
937
+ const maxAttempts = options.maxAttempts ?? 5;
938
+ const baseDelayMs = options.baseDelayMs ?? 10;
939
+ const maxDelayMs = options.maxDelayMs ?? 250;
940
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1)
941
+ throw new TypeError("maxAttempts must be a positive integer");
942
+ if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0)
943
+ throw new TypeError("baseDelayMs must be non-negative");
944
+ if (!Number.isFinite(maxDelayMs) || maxDelayMs < baseDelayMs) {
945
+ throw new TypeError("maxDelayMs must be at least baseDelayMs");
946
+ }
947
+ const transactionOptions = {};
948
+ if (options.readOnly !== undefined)
949
+ transactionOptions.readOnly = options.readOnly;
950
+ if (options.isolation !== undefined)
951
+ transactionOptions.isolation = options.isolation;
952
+ if (options.consistency !== undefined)
953
+ transactionOptions.consistency = options.consistency;
954
+ if (options.sessionCursor !== undefined)
955
+ transactionOptions.sessionCursor = options.sessionCursor;
956
+ if (options.signal !== undefined)
957
+ transactionOptions.signal = options.signal;
958
+ for (let attempt = 1;; attempt += 1) {
959
+ try {
960
+ return await this.withTransaction(fn, transactionOptions);
961
+ }
962
+ catch (error) {
963
+ const conflict = isRindleSqlError(error) && error.code === "TRANSACTION_CONFLICT";
964
+ if (!conflict || attempt >= maxAttempts)
965
+ throw error;
966
+ const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
967
+ await delay(Math.random() * cap, options.signal);
968
+ }
969
+ }
970
+ }
971
+ async executeMutation(input, options) {
972
+ assertMutationIdentity(input);
973
+ if (!Array.isArray(input.statements))
974
+ throw new TypeError("mutation statements must be an array");
975
+ const body = {
976
+ client_id: input.clientId,
977
+ mid: input.mid,
978
+ statements: input.statements.map((statement) => encodeStatement(statement)),
979
+ };
980
+ const receipt = decodeMutationReceipt(await this.transport.request("/v1/sql/mutations/execute", {
981
+ body,
982
+ signal: options?.signal,
983
+ // The server's mid dedup makes a response-loss retry safe and returns the stored lmid.
984
+ retrySafe: true,
985
+ exhaustedRequestRetryScope: "request",
986
+ }));
987
+ acceptCursor(this.cursorState, receipt.commitCursor);
988
+ return receipt;
989
+ }
990
+ async beginMutation(input, options) {
991
+ assertMutationIdentity(input);
992
+ if (input.statements !== undefined && !Array.isArray(input.statements)) {
993
+ throw new TypeError("mutation statements must be an array when present");
994
+ }
995
+ const body = {
996
+ client_id: input.clientId,
997
+ mid: input.mid,
998
+ statements: (input.statements ?? []).map((statement) => encodeStatement(statement)),
999
+ };
1000
+ if (input.query !== undefined)
1001
+ body.query = encodeStatement(input.query);
1002
+ const payload = await this.transport.request("/v1/sql/mutations/transactions", {
1003
+ body,
1004
+ signal: options?.signal,
1005
+ // A lost begin response may have left a live writer transaction, so begin is never retried.
1006
+ retrySafe: false,
1007
+ });
1008
+ if (!isRecord(payload))
1009
+ throw protocolError("server mutation begin response must be an object");
1010
+ if (payload.absorbed === true) {
1011
+ return { absorbed: true, receipt: decodeMutationReceipt(payload) };
1012
+ }
1013
+ const transactionId = requiredString(payload, "sessionId");
1014
+ const result = {
1015
+ absorbed: false,
1016
+ transaction: new MutationTransaction(this.transport, transactionId, this.cursorState),
1017
+ };
1018
+ if (payload.read !== undefined)
1019
+ result.read = decodeMutationRows(payload.read, this.transport.intMode);
1020
+ return result;
1021
+ }
1022
+ async rejectMutation(input, options) {
1023
+ assertMutationIdentity(input);
1024
+ if (input.reason !== undefined && typeof input.reason !== "string") {
1025
+ throw new TypeError("mutation rejection reason must be a string when present");
1026
+ }
1027
+ const body = { client_id: input.clientId, mid: input.mid };
1028
+ if (input.reason !== undefined)
1029
+ body.reason = input.reason;
1030
+ const receipt = decodeMutationReceipt(await this.transport.request("/v1/sql/mutations/reject", {
1031
+ body,
1032
+ signal: options?.signal,
1033
+ retrySafe: true,
1034
+ exhaustedRequestRetryScope: "request",
1035
+ }));
1036
+ acceptCursor(this.cursorState, receipt.commitCursor);
1037
+ return receipt;
1038
+ }
1039
+ async executeDdl(sql, options) {
1040
+ if (typeof sql !== "string" || sql.length === 0)
1041
+ throw new TypeError("sql must be a non-empty string");
1042
+ const body = {
1043
+ statement: encodeStatement(sql),
1044
+ idempotency_key: newIdempotencyKey(),
1045
+ };
1046
+ const result = decodeExecute(await this.transport.request("/v1/sql/execute", { body, signal: options?.signal, retrySafe: true }), this.transport.intMode);
1047
+ acceptCursor(this.cursorState, result.commitCursor);
1048
+ return result;
1049
+ }
1050
+ async migrate(input, options) {
1051
+ if (!isRecord(input) || typeof input.id !== "string" || input.id.length === 0) {
1052
+ throw new TypeError("migration id must be a non-empty string");
1053
+ }
1054
+ if (typeof input.checksum !== "string" || input.checksum.length === 0) {
1055
+ throw new TypeError("migration checksum must be a non-empty string");
1056
+ }
1057
+ if (!Array.isArray(input.statements) || input.statements.length === 0 || !input.statements.every((sql) => typeof sql === "string")) {
1058
+ throw new TypeError("migration statements must be a non-empty string array");
1059
+ }
1060
+ const payload = await this.transport.request("/v1/sql/migrate", {
1061
+ body: { id: input.id, checksum: input.checksum, statements: input.statements },
1062
+ signal: options?.signal,
1063
+ retrySafe: true,
1064
+ exhaustedRequestRetryScope: "request",
1065
+ });
1066
+ if (!isRecord(payload) || typeof payload.applied !== "boolean") {
1067
+ throw protocolError("server migration response must contain applied and commit_cursor");
1068
+ }
1069
+ const commitCursor = requiredString(payload, "commit_cursor");
1070
+ acceptCursor(this.cursorState, commitCursor);
1071
+ return { applied: payload.applied, commitCursor };
1072
+ }
1073
+ async executeMultiple(sql, options) {
1074
+ if (typeof sql !== "string" || sql.length === 0)
1075
+ throw new TypeError("sql must be a non-empty string");
1076
+ const payload = await this.transport.request("/v1/sql/execute-multiple", {
1077
+ body: { sql, idempotency_key: newIdempotencyKey() },
1078
+ signal: options?.signal,
1079
+ retrySafe: true,
1080
+ });
1081
+ if (!isRecord(payload))
1082
+ throw protocolError("server execute-multiple response must be an object");
1083
+ acceptCursor(this.cursorState, nullableString(payload, "commit_cursor"));
1084
+ }
1085
+ session(cursor = null) {
1086
+ if (cursor !== null && typeof cursor !== "string")
1087
+ throw new TypeError("session cursor must be a string or null");
1088
+ return new Session(this.transport, { cursor });
1089
+ }
1090
+ getSessionCursor() {
1091
+ return this.cursorState.cursor;
1092
+ }
1093
+ resetSessionCursor() {
1094
+ this.cursorState.cursor = null;
1095
+ }
1096
+ async ping(options) {
1097
+ await this.transport.request("/version", {
1098
+ method: "GET",
1099
+ signal: options?.signal,
1100
+ authenticated: false,
1101
+ retrySafe: true,
1102
+ expectJson: false,
1103
+ exhaustedRequestRetryScope: "request",
1104
+ });
1105
+ }
1106
+ }
1107
+ class Client extends Session {
1108
+ close() {
1109
+ this.transport.close();
1110
+ }
1111
+ }
1112
+ export function createSqlClient(options) {
1113
+ const transport = new Transport(options);
1114
+ return new Client(transport, { cursor: options.sessionCursor ?? null });
1115
+ }
1116
+ //# sourceMappingURL=client.js.map