@xylex-group/athena 1.2.0 → 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.0`
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
 
@@ -51,7 +51,8 @@ interface AthenaDeletePayload {
51
51
  conditions?: AthenaGatewayCondition[];
52
52
  }
53
53
  interface AthenaUpdatePayload extends AthenaFetchPayload {
54
- update_body?: Record<string, unknown>;
54
+ set?: Record<string, unknown>;
55
+ data?: Record<string, unknown>;
55
56
  }
56
57
  type AthenaRpcFilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'is' | 'in';
57
58
  interface AthenaRpcFilter {
@@ -51,7 +51,8 @@ interface AthenaDeletePayload {
51
51
  conditions?: AthenaGatewayCondition[];
52
52
  }
53
53
  interface AthenaUpdatePayload extends AthenaFetchPayload {
54
- update_body?: Record<string, unknown>;
54
+ set?: Record<string, unknown>;
55
+ data?: Record<string, unknown>;
55
56
  }
56
57
  type AthenaRpcFilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'is' | 'in';
57
58
  interface AthenaRpcFilter {