@xylex-group/athena 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # athena-js
2
2
 
3
- current version: `1.2.1`
3
+ current version: `1.3.0`
4
4
  `@xylex-group/athena` is a database driver and API gateway SDK that lets you interact with SQL backends over HTTP through a fluent builder API. It ships a typed query builder for Node.js / server environments and a React hook for client-side use.
5
5
 
6
6
  ## Install
@@ -43,9 +43,94 @@ if (error) {
43
43
  }
44
44
  ```
45
45
 
46
- Every query resolves to `{ data, error, errorDetails?, status, count?, raw }`. `data` is `null` on error; `error` is `null` on success.
47
-
48
- For richer handling, inspect `errorDetails` (`code`, `status`, `endpoint`, `method`, `requestId`, etc.) or use `AthenaGatewayError` / `isAthenaGatewayError` from the package exports.
46
+ Every query resolves to `{ data, error, errorDetails?, status, count?, raw }`. `data` is `null` on error; `error` is `null` on success.
47
+
48
+ For richer handling, inspect `errorDetails` (`code`, `status`, `endpoint`, `method`, `requestId`, etc.) or use `AthenaGatewayError` / `isAthenaGatewayError` from the package exports.
49
+
50
+ ## Reliability helper APIs
51
+
52
+ The SDK exports composable helpers to reduce repetitive route-handler logic.
53
+
54
+ ### Result unwrapping and success guards
55
+
56
+ ```ts
57
+ import {
58
+ isOk,
59
+ unwrap,
60
+ unwrapRows,
61
+ unwrapOne,
62
+ requireSuccess,
63
+ requireAffected,
64
+ } from "@xylex-group/athena";
65
+
66
+ const result = await athena.from("users").select("id,name");
67
+
68
+ if (isOk(result)) {
69
+ const rows = unwrapRows(result); // typed User[]
70
+ console.log(rows.length);
71
+ }
72
+
73
+ const one = await athena.from("users").eq("id", 1).single("id,name");
74
+ const user = unwrapOne(one, { allowNull: true });
75
+
76
+ const inserted = await athena
77
+ .from("users")
78
+ .insert({ name: "Alice" })
79
+ .select("id", { count: "exact" });
80
+
81
+ requireSuccess(inserted, { table: "users", operation: "insert" });
82
+ requireAffected(inserted, { min: 1 }, { table: "users", operation: "insert" });
83
+ ```
84
+
85
+ `requireAffected` uses `result.count`; request it on writes with `{ count: "exact" }` when you need enforced postconditions.
86
+
87
+ ### Error normalization
88
+
89
+ ```ts
90
+ import { normalizeAthenaError } from "@xylex-group/athena";
91
+
92
+ const result = await athena.from("users").insert({ id: 1 }).select();
93
+ if (result.error) {
94
+ const err = normalizeAthenaError(result, {
95
+ table: "users",
96
+ operation: "insert",
97
+ });
98
+ if (err.kind === "unique_violation") {
99
+ // deterministic conflict handling
100
+ }
101
+ }
102
+ ```
103
+
104
+ Normalized errors expose stable `kind` values (`unique_violation`, `validation`, `auth`, `rate_limit`, `transient`, etc.) plus operation metadata.
105
+
106
+ ### Numeric coercion
107
+
108
+ ```ts
109
+ import { coerceInt, assertInt } from "@xylex-group/athena";
110
+
111
+ const maybeCaseId = coerceInt(req.query.case_id, { min: 1 });
112
+ if (maybeCaseId == null) throw new Error("Invalid case id");
113
+
114
+ const caseId = assertInt(req.query.case_id, "case_id", { min: 1 });
115
+ ```
116
+
117
+ ### Retry helper
118
+
119
+ ```ts
120
+ import { withRetry } from "@xylex-group/athena";
121
+
122
+ const result = await withRetry(
123
+ {
124
+ retries: 3,
125
+ backoff: "exponential",
126
+ baseDelayMs: 100,
127
+ jitter: true,
128
+ },
129
+ () => athena.from("users").select("id,name"),
130
+ );
131
+ ```
132
+
133
+ By default, retries target transient/rate-limit failures; use `shouldRetry` for custom policies.
49
134
 
50
135
  ## Query builder
51
136
 
@@ -64,37 +149,37 @@ const { data } = await athena.from<User>("users").select("id, name");
64
149
 
65
150
  ### Filters
66
151
 
67
- Filters accumulate on the builder and are sent together when the query executes.
68
-
69
- ```ts
70
- const { data } = await athena
152
+ Filters accumulate on the builder and are sent together when the query executes.
153
+
154
+ ```ts
155
+ const { data } = await athena
71
156
  .from("characters")
72
157
  .select("id, name")
73
- .eq("active", true) // column = value
74
- .neq("role", "guest") // column != value
75
- .gt("level", 5) // column > value
76
- .gte("score", 100) // column >= value
77
- .lt("age", 30) // column < value
158
+ .eq("active", true) // column = value
159
+ .neq("role", "guest") // column != value
160
+ .gt("level", 5) // column > value
161
+ .gte("score", 100) // column >= value
162
+ .lt("age", 30) // column < value
78
163
  .lte("created_at", "2024-01-01") // column <= value
79
- .like("name", "Ali%") // SQL LIKE (case-sensitive)
164
+ .like("name", "Ali%") // SQL LIKE (case-sensitive)
80
165
  .ilike("email", "%@example%") // SQL ILIKE (case-insensitive)
81
- .is("deleted_at", null) // IS NULL / IS TRUE etc.
82
- .in("status", ["active", "pending"]) // IN (…)
83
- .contains("tags", ["hero"]) // array contains value
166
+ .is("deleted_at", null) // IS NULL / IS TRUE etc.
167
+ .in("status", ["active", "pending"]) // IN (…)
168
+ .contains("tags", ["hero"]) // array contains value
84
169
  .containedBy("tags", ["hero", "villain"]) // array is subset of value
85
170
  .match({ role: "admin", active: true }) // multiple eq filters at once
86
- .not("role", "eq", "banned") // NOT col op val
87
- .or("status.eq.active,status.eq.pending"); // OR expression
88
- ```
89
-
90
- Canonical style for reads is to call `.select(...)` first, then apply filters:
91
-
92
- ```ts
93
- const { data } = await athena
94
- .from("instruments")
95
- .select("name, section_id")
96
- .eq("name", "violin");
97
- ```
171
+ .not("role", "eq", "banned") // NOT col op val
172
+ .or("status.eq.active,status.eq.pending"); // OR expression
173
+ ```
174
+
175
+ Canonical style for reads is to call `.select(...)` first, then apply filters:
176
+
177
+ ```ts
178
+ const { data } = await athena
179
+ .from("instruments")
180
+ .select("name, section_id")
181
+ .eq("name", "violin");
182
+ ```
98
183
 
99
184
  ### Pagination
100
185
 
@@ -119,40 +204,47 @@ const { data: user } = await athena
119
204
 
120
205
  `maybeSingle` behaves identically — both return the first element of the result set.
121
206
 
122
- ### RPC
123
-
124
- Use `athena.rpc(...)` for Postgres function calls. By default it calls `POST /gateway/rpc`, and with `{ get: true }` it uses the compatibility route `GET /rpc/{function_name}`.
125
-
126
- ```ts
127
- const { data, count } = await athena
128
- .rpc("list_users", { role: "admin" }, { count: "exact", schema: "public" })
129
- .eq("active", true)
130
- .order("created_at", { ascending: false })
131
- .range(0, 24)
132
- .select(["id", "email"]);
133
-
134
- const { data: firstUser } = await athena
135
- .rpc<{ id: number; email: string }>("list_users", { role: "admin" })
136
- .single("id,email");
137
-
138
- const { data: readOnlyUser } = await athena
139
- .rpc<{ id: number; email: string }>("list_users", { role: "admin" }, { get: true, count: "planned", head: true })
140
- .eq("id", 1)
141
- .single("id,email");
142
- ```
143
-
144
- RPC chain methods: `.eq()`, `.neq()`, `.gt()`, `.gte()`, `.lt()`, `.lte()`, `.like()`, `.ilike()`, `.is()`, `.in()`, `.order()`, `.limit()`, `.offset()`, `.range()`, `.select()`, `.single()`, `.maybeSingle()`.
145
- RPC options: `schema`, `count` (`"exact" | "planned" | "estimated"`), `head`, `get`.
146
-
147
- ### Options
207
+ ### RPC
208
+
209
+ Use `athena.rpc(...)` for Postgres function calls. By default it calls `POST /gateway/rpc`, and with `{ get: true }` it uses the compatibility route `GET /rpc/{function_name}`.
210
+
211
+ ```ts
212
+ const { data, count } = await athena
213
+ .rpc("list_users", { role: "admin" }, { count: "exact", schema: "public" })
214
+ .eq("active", true)
215
+ .order("created_at", { ascending: false })
216
+ .range(0, 24)
217
+ .select(["id", "email"]);
218
+
219
+ const { data: firstUser } = await athena
220
+ .rpc<{ id: number; email: string }>("list_users", { role: "admin" })
221
+ .single("id,email");
222
+
223
+ const { data: readOnlyUser } = await athena
224
+ .rpc<{
225
+ id: number;
226
+ email: string;
227
+ }>(
228
+ "list_users",
229
+ { role: "admin" },
230
+ { get: true, count: "planned", head: true },
231
+ )
232
+ .eq("id", 1)
233
+ .single("id,email");
234
+ ```
235
+
236
+ RPC chain methods: `.eq()`, `.neq()`, `.gt()`, `.gte()`, `.lt()`, `.lte()`, `.like()`, `.ilike()`, `.is()`, `.in()`, `.order()`, `.limit()`, `.offset()`, `.range()`, `.select()`, `.single()`, `.maybeSingle()`.
237
+ RPC options: `schema`, `count` (`"exact" | "planned" | "estimated"`), `head`, `get`.
238
+
239
+ ### Options
148
240
 
149
241
  Pass options as the second argument to `.select()`:
150
242
 
151
- | Option | Type | Description |
152
- |--------|------|-------------|
153
- | `count` | `"exact" \| "planned" \| "estimated"` | request a row count alongside the data |
154
- | `head` | `boolean` | return response headers only (no rows) |
155
- | `stripNulls` | `boolean` | strip null values from rows (default `true`) |
243
+ | Option | Type | Description |
244
+ | ------------ | ------------------------------------- | -------------------------------------------- |
245
+ | `count` | `"exact" \| "planned" \| "estimated"` | request a row count alongside the data |
246
+ | `head` | `boolean` | return response headers only (no rows) |
247
+ | `stripNulls` | `boolean` | strip null values from rows (default `true`) |
156
248
 
157
249
  ```ts
158
250
  const { data } = await athena
@@ -178,9 +270,9 @@ const { data } = await athena
178
270
  .insert([{ name: "Frodo" }, { name: "Sam" }])
179
271
  .select();
180
272
 
181
- // Type inference differs by payload shape:
182
- // - insert(one) => AthenaResult<Row>
183
- // - insert(many) => AthenaResult<Row[]>
273
+ // Type inference differs by payload shape:
274
+ // - insert(one) => AthenaResult<Row>
275
+ // - insert(many) => AthenaResult<Row[]>
184
276
  ```
185
277
 
186
278
  ### Update
@@ -206,18 +298,18 @@ const { data } = await athena
206
298
  )
207
299
  .select();
208
300
 
209
- // Type inference differs by payload shape:
210
- // - upsert(one) => AthenaResult<Row>
211
- // - upsert(many) => AthenaResult<Row[]>
301
+ // Type inference differs by payload shape:
302
+ // - upsert(one) => AthenaResult<Row>
303
+ // - upsert(many) => AthenaResult<Row[]>
212
304
  ```
213
305
 
214
- | Option | Type | Description |
215
- |--------|------|-------------|
216
- | `onConflict` | `string \| string[]` | column(s) that determine a conflict |
217
- | `updateBody` | `object` | fields to apply when a conflict occurs |
218
- | `defaultToNull` | `boolean` | write explicit `null` for missing fields |
219
- | `count` | `"exact" \| "planned" \| "estimated"` | request a row count |
220
- | `head` | `boolean` | return headers only |
306
+ | Option | Type | Description |
307
+ | --------------- | ------------------------------------- | ---------------------------------------- |
308
+ | `onConflict` | `string \| string[]` | column(s) that determine a conflict |
309
+ | `updateBody` | `object` | fields to apply when a conflict occurs |
310
+ | `defaultToNull` | `boolean` | write explicit `null` for missing fields |
311
+ | `count` | `"exact" \| "planned" \| "estimated"` | request a row count |
312
+ | `head` | `boolean` | return headers only |
221
313
 
222
314
  ### Delete
223
315
 
@@ -286,7 +378,7 @@ export function UsersPanel() {
286
378
  }
287
379
  ```
288
380
 
289
- The hook returns `fetchGateway`, `insertGateway`, `updateGateway`, `deleteGateway`, `rpcGateway`, `isLoading`, `error`, `lastRequest`, `lastResponse`, and `baseUrl`.
381
+ The hook returns `fetchGateway`, `insertGateway`, `updateGateway`, `deleteGateway`, `rpcGateway`, `isLoading`, `error`, `lastRequest`, `lastResponse`, and `baseUrl`.
290
382
 
291
383
  Hook config options mirror the client options: `baseUrl`, `apiKey`, `headers`, `userId`, `organizationId`, `publishEvent`.
292
384
 
@@ -295,24 +387,32 @@ Hook config options mirror the client options: `baseUrl`, `apiKey`, `headers`, `
295
387
  Pass user and tenant context to every request without repeating it on each call:
296
388
 
297
389
  ```ts
298
- const athena = createClient("https://athena-db.com", process.env.ATHENA_API_KEY, {
299
- headers: {
300
- "X-User-Id": currentUser.id,
301
- "X-Organization-Id": currentUser.organizationId ?? "",
390
+ const athena = createClient(
391
+ "https://athena-db.com",
392
+ process.env.ATHENA_API_KEY,
393
+ {
394
+ headers: {
395
+ "X-User-Id": currentUser.id,
396
+ "X-Organization-Id": currentUser.organizationId ?? "",
397
+ },
302
398
  },
303
- });
399
+ );
304
400
  ```
305
401
 
306
- Or pass per-call via options. The Athena server interprets `url` and `key` based on the configured backend type.
402
+ Or pass per-call via options. The Athena server interprets `url` and `key` based on the configured backend type.
307
403
 
308
404
  ## Custom headers
309
405
 
310
406
  ```ts
311
- const athena = createClient("https://athena-db.com", process.env.ATHENA_API_KEY, {
312
- headers: {
313
- "X-Custom-Header": "value",
407
+ const athena = createClient(
408
+ "https://athena-db.com",
409
+ process.env.ATHENA_API_KEY,
410
+ {
411
+ headers: {
412
+ "X-Custom-Header": "value",
413
+ },
314
414
  },
315
- });
415
+ );
316
416
  ```
317
417
 
318
418
  Per-call headers are merged with the client-level headers, with per-call values winning on conflict.
@@ -329,7 +429,10 @@ interface User {
329
429
  active: boolean;
330
430
  }
331
431
 
332
- const { data } = await athena.from<User>("users").select("id, name").eq("active", true);
432
+ const { data } = await athena
433
+ .from<User>("users")
434
+ .select("id, name")
435
+ .eq("active", true);
333
436
  // data is User[] | null
334
437
  ```
335
438
 
package/dist/index.cjs CHANGED
@@ -964,10 +964,334 @@ var Backend = {
964
964
  ScyllaDB: { type: "scylladb" }
965
965
  };
966
966
 
967
+ // src/auxiliaries.ts
968
+ function isRecord2(value) {
969
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
970
+ }
971
+ function safeStringify(value) {
972
+ try {
973
+ const serialized = JSON.stringify(value);
974
+ return serialized ?? String(value);
975
+ } catch {
976
+ return "[unserializable]";
977
+ }
978
+ }
979
+ function contextHint(context) {
980
+ if (!context?.identity) return void 0;
981
+ const identity = typeof context.identity === "string" ? context.identity : safeStringify(context.identity);
982
+ return `Identity: ${identity}`;
983
+ }
984
+ function isAthenaResultLike(value) {
985
+ return isRecord2(value) && "status" in value && "error" in value && "data" in value && typeof value.status === "number";
986
+ }
987
+ function operationFromDetails(details) {
988
+ if (!details?.endpoint) return void 0;
989
+ if (details.endpoint === "/gateway/fetch" || details.endpoint === "/gateway/query") return "select";
990
+ if (details.endpoint === "/gateway/insert") return "insert";
991
+ if (details.endpoint === "/gateway/update") return "update";
992
+ if (details.endpoint === "/gateway/delete") return "delete";
993
+ if (details.endpoint === "/gateway/rpc" || details.endpoint.startsWith("/rpc/")) return "rpc";
994
+ return void 0;
995
+ }
996
+ function matchRegex(input, patterns) {
997
+ for (const pattern of patterns) {
998
+ const match = pattern.exec(input);
999
+ if (match?.[1]) return match[1];
1000
+ }
1001
+ return void 0;
1002
+ }
1003
+ function extractConstraint(message) {
1004
+ return matchRegex(message, [
1005
+ /unique constraint\s+["'`]([^"'`]+)["'`]/i,
1006
+ /constraint\s+["'`]([^"'`]+)["'`]/i
1007
+ ]);
1008
+ }
1009
+ function extractTable(message) {
1010
+ return matchRegex(message, [
1011
+ /(?:table|relation)\s+["'`]([^"'`]+)["'`]/i,
1012
+ /on\s+table\s+([a-zA-Z0-9_.]+)/i
1013
+ ]);
1014
+ }
1015
+ function classifyKind(status, code, message) {
1016
+ const lower = message.toLowerCase();
1017
+ const hasUniquePattern = lower.includes("unique constraint") || lower.includes("duplicate key") || lower.includes("already exists") || lower.includes("duplicate");
1018
+ const hasNotFoundPattern = lower.includes("not found") || lower.includes("no rows");
1019
+ const hasValidationPattern = lower.includes("validation") || lower.includes("invalid") || lower.includes("malformed");
1020
+ const hasAuthPattern = lower.includes("unauthorized") || lower.includes("forbidden") || lower.includes("auth");
1021
+ const hasRateLimitPattern = lower.includes("rate limit") || lower.includes("too many requests");
1022
+ const hasTransientPattern = lower.includes("timeout") || lower.includes("temporar") || lower.includes("connection reset") || lower.includes("socket") || lower.includes("deadlock");
1023
+ if (status === 409 || hasUniquePattern) return "unique_violation";
1024
+ if (status === 404 || hasNotFoundPattern) return "not_found";
1025
+ if (status === 401 || status === 403 || hasAuthPattern) return "auth";
1026
+ if (status === 429 || hasRateLimitPattern) return "rate_limit";
1027
+ if (status === 400 || status === 422 || hasValidationPattern) return "validation";
1028
+ if (code === "NETWORK_ERROR" || status === 0 || status !== void 0 && status >= 500 || hasTransientPattern) {
1029
+ return "transient";
1030
+ }
1031
+ return "unknown";
1032
+ }
1033
+ function toGatewayCode(kind, status) {
1034
+ if (kind === "transient" && (status === 0 || status === void 0)) return "NETWORK_ERROR";
1035
+ if (kind === "validation") return "INVALID_JSON";
1036
+ if (status !== void 0 && status >= 400) return "HTTP_ERROR";
1037
+ return "UNKNOWN_ERROR";
1038
+ }
1039
+ function toAthenaGatewayError(source, fallbackMessage, context) {
1040
+ if (isAthenaGatewayError(source)) {
1041
+ return source;
1042
+ }
1043
+ if (isAthenaResultLike(source) && source.errorDetails) {
1044
+ return new AthenaGatewayError({
1045
+ code: source.errorDetails.code,
1046
+ message: source.error ?? source.errorDetails.message,
1047
+ status: source.status,
1048
+ endpoint: source.errorDetails.endpoint,
1049
+ method: source.errorDetails.method,
1050
+ requestId: source.errorDetails.requestId,
1051
+ hint: source.errorDetails.hint,
1052
+ cause: source.errorDetails.cause
1053
+ });
1054
+ }
1055
+ const normalized = normalizeAthenaError(source, context);
1056
+ const message = isAthenaResultLike(source) && source.error == null && source.errorDetails == null ? fallbackMessage : normalized.message || fallbackMessage;
1057
+ return new AthenaGatewayError({
1058
+ code: toGatewayCode(normalized.kind, normalized.status),
1059
+ message,
1060
+ status: normalized.status ?? 0,
1061
+ hint: normalized.constraint != null ? `Constraint: ${normalized.constraint}` : contextHint(context),
1062
+ cause: typeof normalized.raw === "string" ? normalized.raw : safeStringify(normalized.raw)
1063
+ });
1064
+ }
1065
+ function isOk(result) {
1066
+ return result.error == null && result.status >= 200 && result.status < 300;
1067
+ }
1068
+ function normalizeAthenaError(resultOrError, context) {
1069
+ if (isAthenaResultLike(resultOrError)) {
1070
+ const details = resultOrError.errorDetails;
1071
+ const message2 = resultOrError.error ?? details?.message ?? `Athena ${context?.operation ?? operationFromDetails(details) ?? "request"} failed`;
1072
+ const operation = context?.operation ?? operationFromDetails(details);
1073
+ const table = context?.table ?? extractTable(message2);
1074
+ const constraint = extractConstraint(message2);
1075
+ const kind = classifyKind(resultOrError.status, details?.code, message2);
1076
+ return {
1077
+ kind,
1078
+ status: resultOrError.status,
1079
+ constraint,
1080
+ table,
1081
+ operation,
1082
+ message: message2,
1083
+ raw: resultOrError.raw
1084
+ };
1085
+ }
1086
+ if (isAthenaGatewayError(resultOrError)) {
1087
+ const details = resultOrError.toDetails();
1088
+ const operation = context?.operation ?? operationFromDetails(details);
1089
+ const table = context?.table ?? extractTable(resultOrError.message);
1090
+ const constraint = extractConstraint(resultOrError.message);
1091
+ const kind = classifyKind(resultOrError.status, resultOrError.code, resultOrError.message);
1092
+ return {
1093
+ kind,
1094
+ status: resultOrError.status,
1095
+ constraint,
1096
+ table,
1097
+ operation,
1098
+ message: resultOrError.message,
1099
+ raw: resultOrError
1100
+ };
1101
+ }
1102
+ if (resultOrError instanceof Error) {
1103
+ const maybeStatus = isRecord2(resultOrError) && typeof resultOrError.status === "number" ? resultOrError.status : void 0;
1104
+ return {
1105
+ kind: classifyKind(maybeStatus, void 0, resultOrError.message),
1106
+ status: maybeStatus,
1107
+ constraint: extractConstraint(resultOrError.message),
1108
+ table: context?.table ?? extractTable(resultOrError.message),
1109
+ operation: context?.operation,
1110
+ message: resultOrError.message,
1111
+ raw: resultOrError
1112
+ };
1113
+ }
1114
+ const message = typeof resultOrError === "string" ? resultOrError : "Unknown Athena error";
1115
+ return {
1116
+ kind: classifyKind(void 0, void 0, message),
1117
+ status: void 0,
1118
+ constraint: extractConstraint(message),
1119
+ table: context?.table ?? extractTable(message),
1120
+ operation: context?.operation,
1121
+ message,
1122
+ raw: resultOrError
1123
+ };
1124
+ }
1125
+ function unwrapRows(result, options) {
1126
+ if (!isOk(result)) {
1127
+ throw toAthenaGatewayError(result, "Athena request failed", options?.context);
1128
+ }
1129
+ if (result.data == null) return [];
1130
+ return Array.isArray(result.data) ? result.data : [result.data];
1131
+ }
1132
+ function unwrap(result, options) {
1133
+ if (!isOk(result)) {
1134
+ throw toAthenaGatewayError(result, "Athena request failed", options?.context);
1135
+ }
1136
+ if (result.data == null && !options?.allowNull) {
1137
+ throw toAthenaGatewayError(result, "Expected data but received null", options?.context);
1138
+ }
1139
+ return result.data;
1140
+ }
1141
+ function unwrapOne(result, options) {
1142
+ const rows = unwrapRows(result, options);
1143
+ if (!rows.length) {
1144
+ if (options?.allowNull) return null;
1145
+ throw toAthenaGatewayError(result, "Expected one row but received none", options?.context);
1146
+ }
1147
+ if (options?.requireExactlyOne && rows.length !== 1) {
1148
+ throw toAthenaGatewayError(
1149
+ result,
1150
+ `Expected exactly one row but received ${rows.length}`,
1151
+ options.context
1152
+ );
1153
+ }
1154
+ return rows[0];
1155
+ }
1156
+ function requireSuccess(result, context) {
1157
+ if (!isOk(result)) {
1158
+ throw toAthenaGatewayError(
1159
+ result,
1160
+ `Athena ${context?.operation ?? "request"} failed`,
1161
+ context
1162
+ );
1163
+ }
1164
+ return result;
1165
+ }
1166
+ function requireAffected(result, options, context) {
1167
+ requireSuccess(result, context);
1168
+ const minimum = options?.min ?? 1;
1169
+ const count = result.count;
1170
+ if (count == null) {
1171
+ throw new AthenaGatewayError({
1172
+ code: "UNKNOWN_ERROR",
1173
+ status: result.status,
1174
+ message: "Expected affected row count but response.count is missing",
1175
+ hint: 'Set call option { count: "exact" } for mutation postcondition checks.',
1176
+ cause: safeStringify(result.raw)
1177
+ });
1178
+ }
1179
+ if (count < minimum) {
1180
+ throw new AthenaGatewayError({
1181
+ code: "UNKNOWN_ERROR",
1182
+ status: result.status,
1183
+ message: `Expected at least ${minimum} affected rows but received ${count}`,
1184
+ hint: contextHint(context),
1185
+ cause: safeStringify(result.raw)
1186
+ });
1187
+ }
1188
+ return count;
1189
+ }
1190
+ function applyBounds(value, options) {
1191
+ if (options?.min !== void 0 && value < options.min) return null;
1192
+ if (options?.max !== void 0 && value > options.max) return null;
1193
+ return value;
1194
+ }
1195
+ function parseIntegerString(value) {
1196
+ const normalized = value.trim();
1197
+ if (!/^[-+]?\d+$/.test(normalized)) return null;
1198
+ const parsed = Number(normalized);
1199
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null;
1200
+ return parsed;
1201
+ }
1202
+ function coerceInt(value, options) {
1203
+ if (value == null) return null;
1204
+ if (typeof value === "number") {
1205
+ if (!Number.isFinite(value) || !Number.isInteger(value)) return null;
1206
+ return applyBounds(value, options);
1207
+ }
1208
+ if (typeof value === "string") {
1209
+ const parsed = parseIntegerString(value);
1210
+ if (parsed == null) return null;
1211
+ return applyBounds(parsed, options);
1212
+ }
1213
+ if (typeof value === "bigint") {
1214
+ if (options?.strictBigInt) {
1215
+ if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) {
1216
+ return null;
1217
+ }
1218
+ }
1219
+ const parsed = Number(value);
1220
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null;
1221
+ return applyBounds(parsed, options);
1222
+ }
1223
+ return null;
1224
+ }
1225
+ function assertInt(value, label = "value", options) {
1226
+ const parsed = coerceInt(value, options);
1227
+ if (parsed == null) {
1228
+ throw new TypeError(`${label} must be a finite integer`);
1229
+ }
1230
+ return parsed;
1231
+ }
1232
+ function defaultShouldRetry(error) {
1233
+ const normalized = normalizeAthenaError(error);
1234
+ return normalized.kind === "transient" || normalized.kind === "rate_limit";
1235
+ }
1236
+ function computeDelayMs(attempt, error, config) {
1237
+ const baseDelay = config.baseDelayMs;
1238
+ const rawDelay = typeof config.backoff === "function" ? config.backoff(attempt, error) : config.backoff === "linear" ? baseDelay * attempt : baseDelay * Math.pow(2, attempt - 1);
1239
+ const safeDelay = Number.isFinite(rawDelay) ? Math.max(0, rawDelay) : 0;
1240
+ const clamped = Math.min(config.maxDelayMs, safeDelay);
1241
+ const jitterFactor = typeof config.jitter === "number" ? Math.max(0, Math.min(1, config.jitter)) : config.jitter ? 0.2 : 0;
1242
+ if (!jitterFactor) return clamped;
1243
+ const deviation = clamped * jitterFactor;
1244
+ const offset = (Math.random() * 2 - 1) * deviation;
1245
+ return Math.max(0, clamped + offset);
1246
+ }
1247
+ function sleep(ms) {
1248
+ if (ms <= 0) return Promise.resolve();
1249
+ return new Promise((resolve) => {
1250
+ setTimeout(resolve, ms);
1251
+ });
1252
+ }
1253
+ async function withRetry(config, fn) {
1254
+ const retries = Math.max(0, Math.trunc(config.retries));
1255
+ const shouldRetry = config.shouldRetry ?? defaultShouldRetry;
1256
+ const resolvedConfig = {
1257
+ baseDelayMs: config.baseDelayMs ?? 100,
1258
+ maxDelayMs: config.maxDelayMs ?? 1e4,
1259
+ backoff: config.backoff ?? "exponential",
1260
+ jitter: config.jitter ?? false
1261
+ };
1262
+ for (let attempts = 0; attempts <= retries; attempts += 1) {
1263
+ try {
1264
+ return await fn();
1265
+ } catch (error) {
1266
+ if (attempts >= retries) {
1267
+ throw error;
1268
+ }
1269
+ const currentAttempt = attempts + 1;
1270
+ const retry = await shouldRetry(error, currentAttempt);
1271
+ if (!retry) {
1272
+ throw error;
1273
+ }
1274
+ const delay = computeDelayMs(currentAttempt, error, resolvedConfig);
1275
+ await sleep(delay);
1276
+ }
1277
+ }
1278
+ throw new Error("withRetry reached an unexpected state");
1279
+ }
1280
+
967
1281
  exports.AthenaClient = AthenaClient;
968
1282
  exports.AthenaGatewayError = AthenaGatewayError;
969
1283
  exports.Backend = Backend;
1284
+ exports.assertInt = assertInt;
1285
+ exports.coerceInt = coerceInt;
970
1286
  exports.createClient = createClient;
971
1287
  exports.isAthenaGatewayError = isAthenaGatewayError;
1288
+ exports.isOk = isOk;
1289
+ exports.normalizeAthenaError = normalizeAthenaError;
1290
+ exports.requireAffected = requireAffected;
1291
+ exports.requireSuccess = requireSuccess;
1292
+ exports.unwrap = unwrap;
1293
+ exports.unwrapOne = unwrapOne;
1294
+ exports.unwrapRows = unwrapRows;
1295
+ exports.withRetry = withRetry;
972
1296
  //# sourceMappingURL=index.cjs.map
973
1297
  //# sourceMappingURL=index.cjs.map