@xylex-group/athena 1.4.0 → 1.5.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,7 +1,7 @@
1
1
  # athena-js
2
2
 
3
- current version: `1.4.0`
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.
3
+ current version: `1.5.0`
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 plus Athena-native React hooks for client-side use.
5
5
 
6
6
  ## Install
7
7
 
@@ -13,7 +13,7 @@ pnpm add @xylex-group/athena
13
13
  yarn add @xylex-group/athena
14
14
  ```
15
15
 
16
- React peer dependency is optional — only needed if you use `useAthenaGateway`.
16
+ React peer dependency is optional — only needed if you use `@xylex-group/athena/react` hooks.
17
17
 
18
18
  ```bash
19
19
  npm install react # React >=17 required for the hook
@@ -156,6 +156,8 @@ const { data } = await athena
156
156
  .from("characters")
157
157
  .select("id, name")
158
158
  .eq("active", true) // column = value
159
+ .eqUuid("session_id", "550e8400-e29b-41d4-a716-446655440000") // explicit UUID cast
160
+ .eqCast("session_id", "550e8400-e29b-41d4-a716-446655440000", "uuid") // explicit cast type
159
161
  .neq("role", "guest") // column != value
160
162
  .gt("level", 5) // column > value
161
163
  .gte("score", 100) // column >= value
@@ -172,6 +174,8 @@ const { data } = await athena
172
174
  .or("status.eq.active,status.eq.pending"); // OR expression
173
175
  ```
174
176
 
177
+ `eq()` now auto-detects UUID-like values on identifier columns (for example `id`, `*_id`, `*uuid*`) and uses a safe typed comparison path so UUID columns no longer require app-side manual `::uuid` / `::text` casts.
178
+
175
179
  Canonical style for reads is to call `.select(...)` first, then apply filters:
176
180
 
177
181
  ```ts
@@ -408,11 +412,102 @@ mutation.finally(() => …);
408
412
 
409
413
  The request fires only once regardless of how many times you call `.then()` or await the object.
410
414
 
411
- ## React hook
415
+ ## React hooks
412
416
 
413
417
  ```tsx
414
418
  "use client";
415
419
 
420
+ import {
421
+ AthenaQueryClientProvider,
422
+ createAthenaQueryClient,
423
+ useAthenaGateway,
424
+ useMutation,
425
+ useQuery,
426
+ } from "@xylex-group/athena/react";
427
+ import { createClient } from "@xylex-group/athena";
428
+
429
+ const queryClient = createAthenaQueryClient({
430
+ cache: { mode: "none" }, // default: no persistent data cache, inflight dedupe only
431
+ });
432
+
433
+ const athena = createClient(
434
+ process.env.NEXT_PUBLIC_ATHENA_URL!,
435
+ process.env.NEXT_PUBLIC_ATHENA_API_KEY!,
436
+ );
437
+
438
+ type Product = {
439
+ id: string;
440
+ name: string;
441
+ price: number;
442
+ };
443
+
444
+ type CreateProductInput = {
445
+ name: string;
446
+ price: number;
447
+ };
448
+
449
+ function ProductsInner() {
450
+ const products = useQuery<Product[]>({
451
+ queryKey: ["products"],
452
+ queryFn: () =>
453
+ athena.from("products").select("id,name,price").limit(50),
454
+ });
455
+
456
+ const createProduct = useMutation<CreateProductInput, Product>({
457
+ mutationFn: (input) =>
458
+ athena.from("products").insert(input).select("id,name,price").single(),
459
+ onSuccess: () => {
460
+ void products.refetch();
461
+ },
462
+ });
463
+
464
+ if (products.isLoading) return <div>Loading...</div>;
465
+ if (products.error) return <div>{products.error.message}</div>;
466
+
467
+ return (
468
+ <div>
469
+ <button
470
+ onClick={() => {
471
+ createProduct.mutate({ name: "New product", price: 99 });
472
+ }}
473
+ >
474
+ Add Product
475
+ </button>
476
+ {products.data?.map((product) => (
477
+ <div key={product.id}>
478
+ {product.name} - {product.price}
479
+ </div>
480
+ ))}
481
+ </div>
482
+ );
483
+ }
484
+
485
+ export function Products() {
486
+ return (
487
+ <AthenaQueryClientProvider client={queryClient}>
488
+ <ProductsInner />
489
+ </AthenaQueryClientProvider>
490
+ );
491
+ }
492
+ ```
493
+
494
+ Available React APIs:
495
+
496
+ - `useAthenaGateway`: low-level gateway hook (`fetchGateway`, `insertGateway`, `updateGateway`, `deleteGateway`, `rpcGateway`) with request/response logging.
497
+ - `createAthenaQueryClient` + `AthenaQueryClientProvider`: Athena query runtime boundary for scoped state and subscriptions.
498
+ - `useQuery`: lightweight read lifecycle hook (`status`, `isFetching`, `refetch`, `reset`) with normalized Athena error/result handling.
499
+ - `useMutation`: lightweight write lifecycle hook (`mutate`, `mutateAsync`, `reset`) with manual refetch/invalidation flow.
500
+
501
+ By design this is not a cache-heavy React Query clone:
502
+
503
+ - No TanStack/React Query dependency.
504
+ - No persistent data cache by default (`cache.mode = "none"`).
505
+ - Inflight dedupe for identical query keys is enabled.
506
+ - Manual `refetch()` after mutations is the default invalidation strategy.
507
+
508
+ `useAthenaGateway` example:
509
+
510
+ ```tsx
416
511
  import { useAthenaGateway } from "@xylex-group/athena/react";
417
512
  import { useEffect } from "react";
418
513
 
@@ -423,7 +518,7 @@ export function UsersPanel() {
423
518
  });
424
519
 
425
520
  useEffect(() => {
426
- fetchGateway({
521
+ void fetchGateway({
427
522
  table_name: "users",
428
523
  columns: ["id", "email"],
429
524
  limit: 25,
@@ -437,9 +532,7 @@ export function UsersPanel() {
437
532
  }
438
533
  ```
439
534
 
440
- The hook returns `fetchGateway`, `insertGateway`, `updateGateway`, `deleteGateway`, `rpcGateway`, `isLoading`, `error`, `lastRequest`, `lastResponse`, and `baseUrl`.
441
-
442
- Hook config options mirror the client options: `baseUrl`, `apiKey`, `headers`, `userId`, `organizationId`, `publishEvent`.
535
+ `useAthenaGateway` config options mirror the client options: `baseUrl`, `apiKey`, `headers`, `userId`, `organizationId`, `publishEvent`.
443
536
 
444
537
  ## User context headers
445
538
 
@@ -8,14 +8,29 @@ type AthenaGatewayEndpointPath = '/gateway/fetch' | '/gateway/insert' | '/gatewa
8
8
  type AthenaCountOption = 'exact' | 'planned' | 'estimated';
9
9
  type AthenaConditionValue = string | number | boolean | null;
10
10
  type AthenaConditionArrayValue = Array<AthenaConditionValue>;
11
+ type AthenaConditionCastType = string;
11
12
  type AthenaConditionOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'is' | 'in' | 'contains' | 'containedBy' | 'not' | 'or';
12
13
  interface AthenaGatewayCondition {
13
14
  column?: string;
14
15
  operator: AthenaConditionOperator;
15
16
  value?: AthenaConditionValue | AthenaConditionArrayValue | string;
17
+ /**
18
+ * Optional explicit cast for `value` (for example `"uuid"`).
19
+ * Older gateways ignore unknown fields; newer gateways may use this hint.
20
+ */
21
+ value_cast?: AthenaConditionCastType;
22
+ /**
23
+ * Optional explicit cast for `column` (for example `"text"`).
24
+ * Used by SDK SQL fallback for typed comparisons.
25
+ */
26
+ column_cast?: AthenaConditionCastType;
16
27
  /** Back-compat shape expected by older gateway implementations */
17
28
  eq_column?: string;
18
29
  eq_value?: AthenaConditionValue | AthenaConditionArrayValue | string;
30
+ /** Optional cast hint aligned with legacy eq_* fields */
31
+ eq_value_cast?: AthenaConditionCastType;
32
+ /** Optional cast hint aligned with legacy eq_* fields */
33
+ eq_column_cast?: AthenaConditionCastType;
19
34
  }
20
35
  type AthenaSortDirection = 'ascending' | 'descending';
21
36
  interface AthenaSortBy {
@@ -205,4 +220,4 @@ declare class AthenaGatewayError extends Error {
205
220
  }
206
221
  declare function isAthenaGatewayError(error: unknown): error is AthenaGatewayError;
207
222
 
208
- export { type AthenaConditionValue as A, type BackendConfig as B, type BackendType as a, type AthenaConditionArrayValue as b, type AthenaConditionOperator as c, type AthenaGatewayCallOptions as d, type AthenaGatewayErrorDetails as e, type AthenaRpcCallOptions as f, AthenaGatewayError as g, type AthenaGatewayErrorCode as h, type AthenaRpcFilter as i, type AthenaRpcFilterOperator as j, type AthenaRpcOrder as k, type AthenaRpcPayload as l, Backend as m, isAthenaGatewayError as n, type AthenaGatewayHookConfig as o, type AthenaGatewayHookResult as p, type AthenaDeletePayload as q, type AthenaFetchPayload as r, type AthenaGatewayResponse as s, type AthenaInsertPayload as t, type AthenaUpdatePayload as u };
223
+ export { type AthenaGatewayCallOptions as A, type BackendConfig as B, type AthenaConditionValue as a, type AthenaConditionCastType as b, type AthenaConditionArrayValue as c, type AthenaConditionOperator as d, type AthenaGatewayErrorDetails as e, type AthenaRpcCallOptions as f, type BackendType as g, Backend as h, AthenaGatewayError as i, isAthenaGatewayError as j, type AthenaRpcFilter as k, type AthenaRpcFilterOperator as l, type AthenaRpcOrder as m, type AthenaRpcPayload as n, type AthenaGatewayErrorCode as o, type AthenaGatewayHookConfig as p, type AthenaGatewayHookResult as q, type AthenaFetchPayload as r, type AthenaInsertPayload as s, type AthenaUpdatePayload as t, type AthenaDeletePayload as u, type AthenaGatewayResponse as v };
@@ -8,14 +8,29 @@ type AthenaGatewayEndpointPath = '/gateway/fetch' | '/gateway/insert' | '/gatewa
8
8
  type AthenaCountOption = 'exact' | 'planned' | 'estimated';
9
9
  type AthenaConditionValue = string | number | boolean | null;
10
10
  type AthenaConditionArrayValue = Array<AthenaConditionValue>;
11
+ type AthenaConditionCastType = string;
11
12
  type AthenaConditionOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'is' | 'in' | 'contains' | 'containedBy' | 'not' | 'or';
12
13
  interface AthenaGatewayCondition {
13
14
  column?: string;
14
15
  operator: AthenaConditionOperator;
15
16
  value?: AthenaConditionValue | AthenaConditionArrayValue | string;
17
+ /**
18
+ * Optional explicit cast for `value` (for example `"uuid"`).
19
+ * Older gateways ignore unknown fields; newer gateways may use this hint.
20
+ */
21
+ value_cast?: AthenaConditionCastType;
22
+ /**
23
+ * Optional explicit cast for `column` (for example `"text"`).
24
+ * Used by SDK SQL fallback for typed comparisons.
25
+ */
26
+ column_cast?: AthenaConditionCastType;
16
27
  /** Back-compat shape expected by older gateway implementations */
17
28
  eq_column?: string;
18
29
  eq_value?: AthenaConditionValue | AthenaConditionArrayValue | string;
30
+ /** Optional cast hint aligned with legacy eq_* fields */
31
+ eq_value_cast?: AthenaConditionCastType;
32
+ /** Optional cast hint aligned with legacy eq_* fields */
33
+ eq_column_cast?: AthenaConditionCastType;
19
34
  }
20
35
  type AthenaSortDirection = 'ascending' | 'descending';
21
36
  interface AthenaSortBy {
@@ -205,4 +220,4 @@ declare class AthenaGatewayError extends Error {
205
220
  }
206
221
  declare function isAthenaGatewayError(error: unknown): error is AthenaGatewayError;
207
222
 
208
- export { type AthenaConditionValue as A, type BackendConfig as B, type BackendType as a, type AthenaConditionArrayValue as b, type AthenaConditionOperator as c, type AthenaGatewayCallOptions as d, type AthenaGatewayErrorDetails as e, type AthenaRpcCallOptions as f, AthenaGatewayError as g, type AthenaGatewayErrorCode as h, type AthenaRpcFilter as i, type AthenaRpcFilterOperator as j, type AthenaRpcOrder as k, type AthenaRpcPayload as l, Backend as m, isAthenaGatewayError as n, type AthenaGatewayHookConfig as o, type AthenaGatewayHookResult as p, type AthenaDeletePayload as q, type AthenaFetchPayload as r, type AthenaGatewayResponse as s, type AthenaInsertPayload as t, type AthenaUpdatePayload as u };
223
+ export { type AthenaGatewayCallOptions as A, type BackendConfig as B, type AthenaConditionValue as a, type AthenaConditionCastType as b, type AthenaConditionArrayValue as c, type AthenaConditionOperator as d, type AthenaGatewayErrorDetails as e, type AthenaRpcCallOptions as f, type BackendType as g, Backend as h, AthenaGatewayError as i, isAthenaGatewayError as j, type AthenaRpcFilter as k, type AthenaRpcFilterOperator as l, type AthenaRpcOrder as m, type AthenaRpcPayload as n, type AthenaGatewayErrorCode as o, type AthenaGatewayHookConfig as p, type AthenaGatewayHookResult as q, type AthenaFetchPayload as r, type AthenaInsertPayload as s, type AthenaUpdatePayload as t, type AthenaDeletePayload as u, type AthenaGatewayResponse as v };
package/dist/index.cjs CHANGED
@@ -368,6 +368,8 @@ function createAthenaGatewayClient(config = {}) {
368
368
 
369
369
  // src/client.ts
370
370
  var DEFAULT_COLUMNS = "*";
371
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
372
+ var SAFE_CAST_PATTERN = /^[a-z_][a-z0-9_]*(?:\[\])?$/i;
371
373
  function formatResult(response) {
372
374
  const result = {
373
375
  data: response.data ?? null,
@@ -448,14 +450,149 @@ function stringifyFilterValue(value) {
448
450
  }
449
451
  return String(value);
450
452
  }
453
+ function isUuidString(value) {
454
+ return UUID_PATTERN.test(value.trim());
455
+ }
456
+ function isUuidIdentifierColumn(column) {
457
+ return column === "id" || /(?:^|_)uuid(?:_|$)/i.test(column) || /_id$/i.test(column);
458
+ }
459
+ function shouldUseUuidTextComparison(column, value) {
460
+ return typeof value === "string" && isUuidString(value) && isUuidIdentifierColumn(column);
461
+ }
462
+ function normalizeCast(cast) {
463
+ const normalized = cast.trim().toLowerCase();
464
+ if (!SAFE_CAST_PATTERN.test(normalized)) {
465
+ throw new Error(`Invalid cast type "${cast}"`);
466
+ }
467
+ return normalized;
468
+ }
469
+ function escapeSqlStringLiteral(value) {
470
+ return value.replace(/'/g, "''");
471
+ }
472
+ function quoteIdentifier(identifier) {
473
+ return `"${identifier.replace(/"/g, '""')}"`;
474
+ }
475
+ function quoteQualifiedIdentifier(identifier) {
476
+ return identifier.split(".").map((segment) => quoteIdentifier(segment)).join(".");
477
+ }
478
+ function toSqlLiteral(value) {
479
+ if (value === null) return "NULL";
480
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
481
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
482
+ return `'${escapeSqlStringLiteral(value)}'`;
483
+ }
484
+ function withCast(expression, cast) {
485
+ if (!cast) return expression;
486
+ return `${expression}::${normalizeCast(cast)}`;
487
+ }
488
+ function buildSelectColumnsClause(columns) {
489
+ if (Array.isArray(columns)) {
490
+ return columns.map((column) => quoteQualifiedIdentifier(column)).join(", ");
491
+ }
492
+ return columns;
493
+ }
494
+ function conditionToSqlClause(condition) {
495
+ if (!condition.column) return null;
496
+ const column = withCast(quoteQualifiedIdentifier(condition.column), condition.column_cast);
497
+ const value = condition.value;
498
+ const sqlOperator = {
499
+ eq: "=",
500
+ neq: "!=",
501
+ gt: ">",
502
+ gte: ">=",
503
+ lt: "<",
504
+ lte: "<=",
505
+ like: "LIKE",
506
+ ilike: "ILIKE"
507
+ };
508
+ switch (condition.operator) {
509
+ case "eq":
510
+ case "neq":
511
+ case "gt":
512
+ case "gte":
513
+ case "lt":
514
+ case "lte":
515
+ case "like":
516
+ case "ilike": {
517
+ if (Array.isArray(value) || value === void 0) return null;
518
+ const rhs = withCast(toSqlLiteral(value), condition.value_cast);
519
+ return `${column} ${sqlOperator[condition.operator]} ${rhs}`;
520
+ }
521
+ case "is": {
522
+ if (value === null) return `${column} IS NULL`;
523
+ if (value === true) return `${column} IS TRUE`;
524
+ if (value === false) return `${column} IS FALSE`;
525
+ return null;
526
+ }
527
+ case "in": {
528
+ if (!Array.isArray(value)) return null;
529
+ if (value.length === 0) return "FALSE";
530
+ const values = value.map((item) => withCast(toSqlLiteral(item), condition.value_cast));
531
+ return `${column} IN (${values.join(", ")})`;
532
+ }
533
+ default:
534
+ return null;
535
+ }
536
+ }
537
+ function buildTypedSelectQuery(input) {
538
+ const whereClauses = [];
539
+ for (const condition of input.conditions) {
540
+ const clause = conditionToSqlClause(condition);
541
+ if (!clause) return null;
542
+ whereClauses.push(clause);
543
+ }
544
+ let limit = input.limit;
545
+ let offset = input.offset;
546
+ if (limit === void 0 && input.pageSize !== void 0) {
547
+ limit = input.pageSize;
548
+ }
549
+ if (offset === void 0 && input.pageSize !== void 0 && input.currentPage !== void 0 && input.currentPage > 0) {
550
+ offset = (input.currentPage - 1) * input.pageSize;
551
+ }
552
+ const sqlParts = [
553
+ `SELECT ${buildSelectColumnsClause(input.columns)} FROM ${quoteQualifiedIdentifier(input.tableName)}`
554
+ ];
555
+ if (whereClauses.length > 0) {
556
+ sqlParts.push(`WHERE ${whereClauses.join(" AND ")}`);
557
+ }
558
+ if (input.order?.field) {
559
+ const direction = input.order.direction === "descending" ? "DESC" : "ASC";
560
+ sqlParts.push(`ORDER BY ${quoteQualifiedIdentifier(input.order.field)} ${direction}`);
561
+ }
562
+ if (limit !== void 0) {
563
+ sqlParts.push(`LIMIT ${Math.max(0, Math.trunc(limit))}`);
564
+ }
565
+ if (offset !== void 0) {
566
+ sqlParts.push(`OFFSET ${Math.max(0, Math.trunc(offset))}`);
567
+ }
568
+ return `${sqlParts.join(" ")};`;
569
+ }
451
570
  function createFilterMethods(state, addCondition, self) {
452
571
  return {
453
572
  eq(column, value) {
454
- addCondition("eq", column, value);
573
+ if (shouldUseUuidTextComparison(column, value)) {
574
+ addCondition("eq", column, value, { columnCast: "text" });
575
+ } else {
576
+ addCondition("eq", column, value);
577
+ }
578
+ return self;
579
+ },
580
+ eqCast(column, value, cast) {
581
+ addCondition("eq", column, value, { valueCast: cast });
582
+ return self;
583
+ },
584
+ eqUuid(column, value) {
585
+ addCondition("eq", column, value, { valueCast: "uuid" });
455
586
  return self;
456
587
  },
457
588
  match(filters) {
458
- Object.entries(filters).forEach(([column, value]) => addCondition("eq", column, value));
589
+ Object.entries(filters).forEach(([column, value]) => {
590
+ if (shouldUseUuidTextComparison(column, value)) {
591
+ addCondition("eq", column, value, { columnCast: "text" });
592
+ } else {
593
+ addCondition("eq", column, value);
594
+ }
595
+ });
459
596
  return self;
460
597
  },
461
598
  range(from, to) {
@@ -679,7 +816,7 @@ function createTableBuilder(tableName, client) {
679
816
  const state = {
680
817
  conditions: []
681
818
  };
682
- const addCondition = (operator, column, value) => {
819
+ const addCondition = (operator, column, value, hints) => {
683
820
  const condition = { operator };
684
821
  if (column) {
685
822
  condition.column = column;
@@ -693,15 +830,47 @@ function createTableBuilder(tableName, client) {
693
830
  condition.eq_value = value;
694
831
  }
695
832
  }
833
+ if (hints?.valueCast) {
834
+ condition.value_cast = hints.valueCast;
835
+ if (operator === "eq") {
836
+ condition.eq_value_cast = hints.valueCast;
837
+ }
838
+ }
839
+ if (hints?.columnCast) {
840
+ condition.column_cast = hints.columnCast;
841
+ if (operator === "eq") {
842
+ condition.eq_column_cast = hints.columnCast;
843
+ }
844
+ }
696
845
  state.conditions.push(condition);
697
846
  };
698
847
  const builder = {};
699
848
  const filterMethods = createFilterMethods(state, addCondition, builder);
700
849
  const runSelect = async (columns = DEFAULT_COLUMNS, options) => {
850
+ const conditions = state.conditions.length ? state.conditions.map((condition) => ({ ...condition })) : void 0;
851
+ const hasTypedEqualityComparison = conditions?.some(
852
+ (condition) => condition.operator === "eq" && (condition.value_cast !== void 0 || condition.column_cast !== void 0)
853
+ ) ?? false;
854
+ if (hasTypedEqualityComparison && !options?.head && !options?.count && conditions) {
855
+ const query = buildTypedSelectQuery({
856
+ tableName,
857
+ columns,
858
+ conditions,
859
+ limit: state.limit,
860
+ offset: state.offset,
861
+ currentPage: state.currentPage,
862
+ pageSize: state.pageSize,
863
+ order: state.order
864
+ });
865
+ if (query) {
866
+ const queryResponse = await client.queryGateway({ query }, options);
867
+ return formatResult(queryResponse);
868
+ }
869
+ }
701
870
  const payload = {
702
871
  table_name: tableName,
703
872
  columns,
704
- conditions: state.conditions.length ? [...state.conditions] : void 0,
873
+ conditions,
705
874
  limit: state.limit,
706
875
  offset: state.offset,
707
876
  current_page: state.currentPage,