@xylex-group/athena 1.2.1 → 1.4.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.4.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,48 +149,107 @@ 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
 
186
+ Two styles, pick whichever matches your UI / backend. Both live on the shared `FilterChain`, so they work before or after `.select()`.
187
+
101
188
  ```ts
102
- // explicit limit and offset
189
+ // 1. offset / limit contiguous windows
103
190
  const { data } = await athena.from("users").select().limit(25).offset(50);
104
191
 
105
- // range shorthand equivalent to offset(from).limit(to - from + 1)
106
- const { data } = await athena.from("users").select().range(0, 24);
192
+ // range shorthand: offset = from, limit = to - from + 1
193
+ const { data: firstTwentyFive } = await athena.from("users").select().range(0, 24);
194
+
195
+ // 2. page based — maps to current_page / page_size / total_pages
196
+ const { data: page2 } = await athena
197
+ .from("orders")
198
+ .select("id, total")
199
+ .currentPage(2)
200
+ .pageSize(25);
201
+
202
+ // .totalPages() is an optional hint some backends use in the response envelope
203
+ const { data: hinted } = await athena
204
+ .from("orders")
205
+ .select("id, total")
206
+ .currentPage(1)
207
+ .pageSize(25)
208
+ .totalPages(10);
107
209
  ```
108
210
 
211
+ | Method | Body field |
212
+ |--------|------------|
213
+ | `.limit(n)` | `limit` |
214
+ | `.offset(n)` | `offset` |
215
+ | `.range(from, to)` | `offset` + `limit` |
216
+ | `.currentPage(n)` | `current_page` |
217
+ | `.pageSize(n)` | `page_size` |
218
+ | `.totalPages(n)` | `total_pages` |
219
+
220
+ ### Ordering
221
+
222
+ `.order(column, { ascending? })` is available on the table builder, select chain, update chain, and delete — before or after the operation terminator. It serializes to `sort_by: { field, direction }` on the gateway payload and defaults to ascending.
223
+
224
+ ```ts
225
+ // descending + limit
226
+ // SELECT * FROM rsf_messages WHERE room_id = $1 ORDER BY created_at DESC LIMIT 100
227
+ const { data } = await athena
228
+ .from("rsf_messages")
229
+ .eq("room_id", roomId)
230
+ .select("*", { stripNulls: false })
231
+ .order("created_at", { ascending: false })
232
+ .limit(100);
233
+
234
+ // ascending (default) + page-based pagination
235
+ const { data: page } = await athena
236
+ .from("orders")
237
+ .select("id, total, created_at")
238
+ .order("created_at")
239
+ .currentPage(1)
240
+ .pageSize(25);
241
+
242
+ // combine with .single() to grab the newest / oldest row
243
+ const { data: latest } = await athena
244
+ .from("messages")
245
+ .eq("room_id", roomId)
246
+ .select("*")
247
+ .order("created_at", { ascending: false })
248
+ .single();
249
+ ```
250
+
251
+ Only the last `.order()` wins — the SDK does not support multi-column ordering on the table builder. Use `.rpc()` or `.query()` for that.
252
+
109
253
  ### Single row
110
254
 
111
255
  ```ts
@@ -119,40 +263,47 @@ const { data: user } = await athena
119
263
 
120
264
  `maybeSingle` behaves identically — both return the first element of the result set.
121
265
 
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
266
+ ### RPC
267
+
268
+ 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}`.
269
+
270
+ ```ts
271
+ const { data, count } = await athena
272
+ .rpc("list_users", { role: "admin" }, { count: "exact", schema: "public" })
273
+ .eq("active", true)
274
+ .order("created_at", { ascending: false })
275
+ .range(0, 24)
276
+ .select(["id", "email"]);
277
+
278
+ const { data: firstUser } = await athena
279
+ .rpc<{ id: number; email: string }>("list_users", { role: "admin" })
280
+ .single("id,email");
281
+
282
+ const { data: readOnlyUser } = await athena
283
+ .rpc<{
284
+ id: number;
285
+ email: string;
286
+ }>(
287
+ "list_users",
288
+ { role: "admin" },
289
+ { get: true, count: "planned", head: true },
290
+ )
291
+ .eq("id", 1)
292
+ .single("id,email");
293
+ ```
294
+
295
+ RPC chain methods: `.eq()`, `.neq()`, `.gt()`, `.gte()`, `.lt()`, `.lte()`, `.like()`, `.ilike()`, `.is()`, `.in()`, `.order()`, `.limit()`, `.offset()`, `.range()`, `.select()`, `.single()`, `.maybeSingle()`.
296
+ RPC options: `schema`, `count` (`"exact" | "planned" | "estimated"`), `head`, `get`.
297
+
298
+ ### Options
148
299
 
149
300
  Pass options as the second argument to `.select()`:
150
301
 
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`) |
302
+ | Option | Type | Description |
303
+ | ------------ | ------------------------------------- | -------------------------------------------- |
304
+ | `count` | `"exact" \| "planned" \| "estimated"` | request a row count alongside the data |
305
+ | `head` | `boolean` | return response headers only (no rows) |
306
+ | `stripNulls` | `boolean` | strip null values from rows (default `true`) |
156
307
 
157
308
  ```ts
158
309
  const { data } = await athena
@@ -178,9 +329,9 @@ const { data } = await athena
178
329
  .insert([{ name: "Frodo" }, { name: "Sam" }])
179
330
  .select();
180
331
 
181
- // Type inference differs by payload shape:
182
- // - insert(one) => AthenaResult<Row>
183
- // - insert(many) => AthenaResult<Row[]>
332
+ // Type inference differs by payload shape:
333
+ // - insert(one) => AthenaResult<Row>
334
+ // - insert(many) => AthenaResult<Row[]>
184
335
  ```
185
336
 
186
337
  ### Update
@@ -206,18 +357,18 @@ const { data } = await athena
206
357
  )
207
358
  .select();
208
359
 
209
- // Type inference differs by payload shape:
210
- // - upsert(one) => AthenaResult<Row>
211
- // - upsert(many) => AthenaResult<Row[]>
360
+ // Type inference differs by payload shape:
361
+ // - upsert(one) => AthenaResult<Row>
362
+ // - upsert(many) => AthenaResult<Row[]>
212
363
  ```
213
364
 
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 |
365
+ | Option | Type | Description |
366
+ | --------------- | ------------------------------------- | ---------------------------------------- |
367
+ | `onConflict` | `string \| string[]` | column(s) that determine a conflict |
368
+ | `updateBody` | `object` | fields to apply when a conflict occurs |
369
+ | `defaultToNull` | `boolean` | write explicit `null` for missing fields |
370
+ | `count` | `"exact" \| "planned" \| "estimated"` | request a row count |
371
+ | `head` | `boolean` | return headers only |
221
372
 
222
373
  ### Delete
223
374
 
@@ -286,7 +437,7 @@ export function UsersPanel() {
286
437
  }
287
438
  ```
288
439
 
289
- The hook returns `fetchGateway`, `insertGateway`, `updateGateway`, `deleteGateway`, `rpcGateway`, `isLoading`, `error`, `lastRequest`, `lastResponse`, and `baseUrl`.
440
+ The hook returns `fetchGateway`, `insertGateway`, `updateGateway`, `deleteGateway`, `rpcGateway`, `isLoading`, `error`, `lastRequest`, `lastResponse`, and `baseUrl`.
290
441
 
291
442
  Hook config options mirror the client options: `baseUrl`, `apiKey`, `headers`, `userId`, `organizationId`, `publishEvent`.
292
443
 
@@ -295,28 +446,40 @@ Hook config options mirror the client options: `baseUrl`, `apiKey`, `headers`, `
295
446
  Pass user and tenant context to every request without repeating it on each call:
296
447
 
297
448
  ```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 ?? "",
449
+ const athena = createClient(
450
+ "https://athena-db.com",
451
+ process.env.ATHENA_API_KEY,
452
+ {
453
+ headers: {
454
+ "X-User-Id": currentUser.id,
455
+ "X-Organization-Id": currentUser.organizationId ?? "",
456
+ },
302
457
  },
303
- });
458
+ );
304
459
  ```
305
460
 
306
- Or pass per-call via options. The Athena server interprets `url` and `key` based on the configured backend type.
461
+ Or pass per-call via options. The Athena server interprets `url` and `key` based on the configured backend type.
307
462
 
308
463
  ## Custom headers
309
464
 
310
465
  ```ts
311
- const athena = createClient("https://athena-db.com", process.env.ATHENA_API_KEY, {
312
- headers: {
313
- "X-Custom-Header": "value",
466
+ const athena = createClient(
467
+ "https://athena-db.com",
468
+ process.env.ATHENA_API_KEY,
469
+ {
470
+ headers: {
471
+ "X-Custom-Header": "value",
472
+ },
314
473
  },
315
- });
474
+ );
316
475
  ```
317
476
 
318
477
  Per-call headers are merged with the client-level headers, with per-call values winning on conflict.
319
478
 
479
+ The SDK also sends a standard identification header on every request:
480
+
481
+ - `X-Athena-Sdk: xylex-group/athena <version>`
482
+
320
483
  ## TypeScript
321
484
 
322
485
  The package is written in TypeScript and ships declaration files. Pass a row type to `.from()` for fully-typed builder methods and results:
@@ -329,7 +492,10 @@ interface User {
329
492
  active: boolean;
330
493
  }
331
494
 
332
- const { data } = await athena.from<User>("users").select("id, name").eq("active", true);
495
+ const { data } = await athena
496
+ .from<User>("users")
497
+ .select("id, name")
498
+ .eq("active", true);
333
499
  // data is User[] | null
334
500
  ```
335
501
 
@@ -17,6 +17,11 @@ interface AthenaGatewayCondition {
17
17
  eq_column?: string;
18
18
  eq_value?: AthenaConditionValue | AthenaConditionArrayValue | string;
19
19
  }
20
+ type AthenaSortDirection = 'ascending' | 'descending';
21
+ interface AthenaSortBy {
22
+ field: string;
23
+ direction: AthenaSortDirection;
24
+ }
20
25
  interface AthenaFetchPayload {
21
26
  view_name?: string;
22
27
  table_name?: string;
@@ -33,6 +38,7 @@ interface AthenaFetchPayload {
33
38
  aggregation_column?: string;
34
39
  aggregation_strategy?: 'cumulative_sum';
35
40
  aggregation_dedup?: boolean;
41
+ sort_by?: AthenaSortBy;
36
42
  }
37
43
  interface AthenaInsertPayload {
38
44
  table_name: string;
@@ -49,6 +55,10 @@ interface AthenaDeletePayload {
49
55
  resource_id?: string;
50
56
  columns?: string[] | string;
51
57
  conditions?: AthenaGatewayCondition[];
58
+ sort_by?: AthenaSortBy;
59
+ current_page?: number;
60
+ page_size?: number;
61
+ total_pages?: number;
52
62
  }
53
63
  interface AthenaUpdatePayload extends AthenaFetchPayload {
54
64
  set?: Record<string, unknown>;
@@ -17,6 +17,11 @@ interface AthenaGatewayCondition {
17
17
  eq_column?: string;
18
18
  eq_value?: AthenaConditionValue | AthenaConditionArrayValue | string;
19
19
  }
20
+ type AthenaSortDirection = 'ascending' | 'descending';
21
+ interface AthenaSortBy {
22
+ field: string;
23
+ direction: AthenaSortDirection;
24
+ }
20
25
  interface AthenaFetchPayload {
21
26
  view_name?: string;
22
27
  table_name?: string;
@@ -33,6 +38,7 @@ interface AthenaFetchPayload {
33
38
  aggregation_column?: string;
34
39
  aggregation_strategy?: 'cumulative_sum';
35
40
  aggregation_dedup?: boolean;
41
+ sort_by?: AthenaSortBy;
36
42
  }
37
43
  interface AthenaInsertPayload {
38
44
  table_name: string;
@@ -49,6 +55,10 @@ interface AthenaDeletePayload {
49
55
  resource_id?: string;
50
56
  columns?: string[] | string;
51
57
  conditions?: AthenaGatewayCondition[];
58
+ sort_by?: AthenaSortBy;
59
+ current_page?: number;
60
+ page_size?: number;
61
+ total_pages?: number;
52
62
  }
53
63
  interface AthenaUpdatePayload extends AthenaFetchPayload {
54
64
  set?: Record<string, unknown>;