@burojs/hasura 0.4.0 → 0.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
@@ -56,22 +56,21 @@ registry rather than by branching on resource names:
56
56
  | `dataBody` | route a heavy array payload into/out of a 1:1 sibling table |
57
57
  | `live` | opt this resource into subscription-driven invalidation |
58
58
 
59
- #### `recoverAuth` fires on HTTP 401 — which is not how Hasura rejects a credential
60
-
61
- `config.recoverAuth` is consulted when a request comes back with HTTP **401**,
62
- and only then. The one capture in this repository of a real Hasura rejection —
63
- `packages/mock-kit/fixtures/hasura/error-unauthorized.json`, taken from a live
64
- backend with a deliberately malformed bearer token — came back **HTTP 200 with
65
- an `invalid-jwt` errors envelope**. Replayed through this provider that
66
- surfaces as `{ kind: 'protocol', code: 'graphql-error' }`, `recoverAuth` is
67
- never called, and app logic keyed on `kind: 'auth'` never fires either.
68
-
69
- So `recoverAuth` is useful for a deployment that really answers 401 (a proxy or
70
- gateway in front of Hasura); against Hasura's own rejection shape it is inert.
71
- The behaviour is pinned by `tests/fixture-fidelity.test.ts`'s
72
- `error-unauthorized` case, and the package's existing 401-recovery tests use a
73
- fabricated 401 — they are not evidence about Hasura. Keying recovery on the
74
- error envelope is a product decision this package has not taken.
59
+ #### Errors Hasura sends with HTTP 200
60
+
61
+ Hasura answers every GraphQL-level failure with HTTP 200, so the provider
62
+ classifies the envelope by `extensions.code`: `access-denied` and
63
+ `permission-error` reject as `{ kind: 'forbidden', code }`, `invalid-jwt` as
64
+ `{ kind: 'auth', code: 'invalid-jwt' }`, and every other code as
65
+ `{ kind: 'protocol', code: 'graphql-error' }`. When the app passes an
66
+ `authProvider` to `<BuroProvider>`, an `auth` rejection reaches its `onError`,
67
+ which decides whether to refresh; on `refreshed` the runtime replays a read once
68
+ (a mutation once when it is replay-safe). `config.recoverAuth` remains the hook
69
+ for an HTTP **401**, which a proxy or gateway in front of Hasura answers; it is
70
+ not consulted for the envelope. A mutation whose request was sent and got no
71
+ answer at all rejects as `{ kind: 'sent', code: 'hasura-request-sent',
72
+ retryable: false }`: it may have been applied. Pinned by
73
+ `tests/fixture-fidelity.test.ts` and `tests/error-kinds.test.ts`.
75
74
 
76
75
  Optimistic concurrency is available per call: pass
77
76
  `providerOptions.ifUpdatedAt` on an update and the mutation switches to a
package/dist/index.cjs CHANGED
@@ -343,6 +343,63 @@ function flattenJsonbSpill(item, spill) {
343
343
  return out;
344
344
  }
345
345
  const NOT_AUTHENTICATED_CODE = "not-authenticated";
346
+ const FORBIDDEN_CODES = new Set(["access-denied", "permission-error"]);
347
+ const HASURA_EXTERNAL_FAILURE_CODES = new Set([
348
+ "unexpected",
349
+ "remote-schema-error",
350
+ "parse-failed"
351
+ ]);
352
+ const MUTATION_REFUSAL_CODES = new Map([["constraint-violation", "conflict"], ["validation-failed", "validation"]]);
353
+ function graphqlError(errors, operation, external) {
354
+ const message = errors.map((e) => e.message).join("; ");
355
+ const codes = errors.flatMap((e) => typeof e.extensions?.code === "string" ? [e.extensions.code] : []);
356
+ if (codes.includes("invalid-jwt")) return {
357
+ kind: "auth",
358
+ code: "invalid-jwt",
359
+ message,
360
+ details: errors
361
+ };
362
+ const outside = operation === "mutation" && external ? codes.find((code) => HASURA_EXTERNAL_FAILURE_CODES.has(code) || MUTATION_REFUSAL_CODES.has(code) || FORBIDDEN_CODES.has(code)) : void 0;
363
+ if (outside !== void 0) return {
364
+ kind: "sent",
365
+ code: outside,
366
+ message,
367
+ retryable: false,
368
+ details: errors
369
+ };
370
+ const forbidden = codes.find((code) => FORBIDDEN_CODES.has(code));
371
+ if (forbidden !== void 0) return {
372
+ kind: "forbidden",
373
+ code: forbidden,
374
+ message,
375
+ details: errors
376
+ };
377
+ if (operation === "mutation") for (const code of codes) {
378
+ const kind = MUTATION_REFUSAL_CODES.get(code);
379
+ if (kind !== void 0) return {
380
+ kind,
381
+ code,
382
+ message,
383
+ details: errors
384
+ };
385
+ }
386
+ return {
387
+ kind: "protocol",
388
+ code: "graphql-error",
389
+ message,
390
+ details: errors
391
+ };
392
+ }
393
+ function answerLost(code, message, status, cause) {
394
+ return {
395
+ kind: "sent",
396
+ code,
397
+ message,
398
+ status,
399
+ retryable: false,
400
+ ...cause === void 0 ? {} : { cause }
401
+ };
402
+ }
346
403
  const LOCALE_REQUIRED_CODE = "locale-required";
347
404
  function localeWhere(m, locale) {
348
405
  if (m.localeField === void 0) return void 0;
@@ -499,8 +556,12 @@ function createHasuraDataProvider(config) {
499
556
  const operations = snapshotOperationRegistry(config.operations);
500
557
  const hasCustomQueries = hasRegisteredOperationKind(operations, "query");
501
558
  const hasCustomMutations = hasRegisteredOperationKind(operations, "mutation");
502
- async function gql(query, variables, role, signal) {
559
+ async function gql(query, variables, role, signal, operation, external = false) {
503
560
  try {
561
+ const requestBody = JSON.stringify({
562
+ query,
563
+ variables
564
+ });
504
565
  const request = async (token) => {
505
566
  const headers = {
506
567
  "Content-Type": "application/json",
@@ -508,15 +569,24 @@ function createHasuraDataProvider(config) {
508
569
  };
509
570
  const effectiveRole = role === void 0 ? defaultRole : normalizeRole(role);
510
571
  if (effectiveRole !== null) headers["X-Hasura-Role"] = effectiveRole;
511
- return (config.fetchImpl ?? fetch)(endpoint, {
512
- method: "POST",
513
- headers,
514
- body: JSON.stringify({
515
- query,
516
- variables
517
- }),
518
- signal
519
- });
572
+ const fetchImpl = config.fetchImpl ?? fetch;
573
+ try {
574
+ return await fetchImpl(endpoint, {
575
+ method: "POST",
576
+ headers,
577
+ body: requestBody,
578
+ signal
579
+ });
580
+ } catch (cause) {
581
+ if (operation === "mutation" && !signal.aborted) throw {
582
+ kind: "sent",
583
+ code: "hasura-request-sent",
584
+ message: "Hasura did not answer a mutation; it may have been applied.",
585
+ retryable: false,
586
+ cause
587
+ };
588
+ throw cause;
589
+ }
520
590
  };
521
591
  let token = await config.getToken();
522
592
  if (!token) throw {
@@ -534,24 +604,32 @@ function createHasuraDataProvider(config) {
534
604
  };
535
605
  res = await request(token);
536
606
  }
537
- if (!res.ok) throw {
538
- kind: "transport",
539
- code: "graphql-http-error",
540
- message: `GraphQL HTTP ${res.status}`,
541
- status: res.status
542
- };
543
- const body = await res.json();
544
- if (body.errors?.length) throw {
545
- kind: "protocol",
546
- code: "graphql-error",
547
- message: body.errors.map((e) => e.message).join("; "),
548
- details: body.errors
549
- };
550
- if (body.data === void 0) throw {
551
- kind: "protocol",
552
- code: "empty-graphql-response",
553
- message: "Empty GraphQL response."
554
- };
607
+ if (!res.ok) {
608
+ if (operation === "mutation" && res.status >= 500) throw answerLost("hasura-answer-lost", `GraphQL HTTP ${res.status} after a mutation was sent; it may have been applied.`, res.status);
609
+ throw {
610
+ kind: "transport",
611
+ code: "graphql-http-error",
612
+ message: `GraphQL HTTP ${res.status}`,
613
+ status: res.status
614
+ };
615
+ }
616
+ let body;
617
+ try {
618
+ body = await res.json();
619
+ if (body === null || typeof body !== "object") throw new TypeError("The GraphQL answer is not an object.");
620
+ } catch (cause) {
621
+ if (operation === "mutation" && !signal.aborted) throw answerLost("hasura-answer-unreadable", "Hasura answered a mutation with a body that could not be read; it may have been applied.", res.status, cause);
622
+ throw cause;
623
+ }
624
+ if (body.errors?.length) throw graphqlError(body.errors, operation, external);
625
+ if (body.data === void 0) {
626
+ if (operation === "mutation") throw answerLost("hasura-answer-unreadable", "Hasura answered a mutation without data or errors; it may have been applied.", res.status);
627
+ throw {
628
+ kind: "protocol",
629
+ code: "empty-graphql-response",
630
+ message: "Empty GraphQL response."
631
+ };
632
+ }
555
633
  return body.data;
556
634
  } catch (error) {
557
635
  throw hasuraError(error, signal, "Hasura request failed.");
@@ -577,8 +655,8 @@ function createHasuraDataProvider(config) {
577
655
  }
578
656
  async function executeCustomOperation(resource, operationName, input, kind, signal) {
579
657
  const operation = registeredOperation(resource, operationName, kind);
580
- const result = await gql(operation.document, operation.variables(input), operation.role ?? config.resources[resource]?.role, signal);
581
- return operation.mapResult?.(result) ?? result;
658
+ const result = await gql(operation.document, operation.variables(input), operation.role ?? config.resources[resource]?.role, signal, kind, kind === "mutation");
659
+ return operation.mapResult === void 0 ? result : operation.mapResult(result);
582
660
  }
583
661
  function composeListWhere(m, descriptor, context) {
584
662
  const userWhere = filtersToWhere(coerceInt64Filter(descriptor.filter, m.int64Fields));
@@ -597,7 +675,7 @@ function createHasuraDataProvider(config) {
597
675
  }`;
598
676
  const aggVars = {};
599
677
  if (where) aggVars.where = where;
600
- return (await gql(metaQuery, where ? aggVars : void 0, m.role, signal)).meta.aggregate.count;
678
+ return (await gql(metaQuery, where ? aggVars : void 0, m.role, signal, "query")).meta.aggregate.count;
601
679
  }
602
680
  async function upsertDataBody(db, parentId, body, role, signal) {
603
681
  await gql(`mutation UpsertDataBody($object: ${db.table}_insert_input!) {
@@ -608,11 +686,11 @@ function createHasuraDataProvider(config) {
608
686
  }`, { object: {
609
687
  [db.fk]: parentId,
610
688
  [db.column]: body
611
- } }, role, signal);
689
+ } }, role, signal, "mutation");
612
690
  }
613
691
  function mutationRecord(m, idField, item, resource, expectedId) {
614
692
  if (item === null || typeof item !== "object" || Array.isArray(item)) throw {
615
- kind: "protocol",
693
+ kind: "not-found",
616
694
  code: "record-not-found",
617
695
  message: `Not found: ${resource}`
618
696
  };
@@ -643,11 +721,11 @@ function createHasuraDataProvider(config) {
643
721
  const where = mergeWhere({ [pkField]: { _eq: id } }, m.fixedFilter);
644
722
  return (await gql(`query One_${m.table}($where: ${m.table}_bool_exp) {
645
723
  items: ${m.table}(where: $where, limit: 1) { ${fields} }
646
- }`, { where }, m.role, signal)).items?.[0] ?? null;
724
+ }`, { where }, m.role, signal, "query")).items?.[0] ?? null;
647
725
  }
648
726
  return (await gql(`query One_${m.table}($id: ${pkType}!) {
649
727
  item: ${root}(${pkField}: $id) { ${fields} }
650
- }`, { id }, m.role, signal)).item;
728
+ }`, { id }, m.role, signal, "query")).item;
651
729
  }
652
730
  async function listByCursor(m, descriptor, pagination, context) {
653
731
  if (pagination.before !== void 0) throw {
@@ -670,7 +748,7 @@ function createHasuraDataProvider(config) {
670
748
  }`;
671
749
  const itemsVars = { limit: pagination.limit + 1 };
672
750
  if (where) itemsVars.where = where;
673
- const fetched = (await gql(itemsQuery, itemsVars, m.role, context.signal)).items ?? [];
751
+ const fetched = (await gql(itemsQuery, itemsVars, m.role, context.signal, "query")).items ?? [];
674
752
  const hasNextPage = fetched.length > pagination.limit;
675
753
  const rows = hasNextPage ? fetched.slice(0, pagination.limit) : fetched;
676
754
  let total;
@@ -715,7 +793,7 @@ function createHasuraDataProvider(config) {
715
793
  offset
716
794
  };
717
795
  if (where) itemsVars.where = where;
718
- const itemsData = await gql(itemsQuery, itemsVars, m.role, context.signal);
796
+ const itemsData = await gql(itemsQuery, itemsVars, m.role, context.signal, "query");
719
797
  let total;
720
798
  const listAggRoot = aggregateRoot(m);
721
799
  if (!m.noAggregate && listAggRoot) try {
@@ -744,7 +822,7 @@ function createHasuraDataProvider(config) {
744
822
  const fields = m.selectFields.join(" ");
745
823
  const data = await gql(`query Many_${m.table}($where: ${m.table}_bool_exp) {
746
824
  items: ${m.table}(where: $where) { ${fields} }
747
- }`, { where }, m.role, context.signal);
825
+ }`, { where }, m.role, context.signal, "query");
748
826
  const spill = m.jsonbSpill;
749
827
  return { data: spill ? data.items.map((item) => flattenJsonbSpill(item, spill)) : data.items };
750
828
  },
@@ -761,12 +839,12 @@ function createHasuraDataProvider(config) {
761
839
  const where = mergeWhere(mergeWhere({ [pkField]: { _eq: descriptor.id } }, m.fixedFilter), localeSlice);
762
840
  item = (await gql(`query One_${m.table}($where: ${m.table}_bool_exp) {
763
841
  items: ${m.table}(where: $where, limit: 1) { ${fields} }
764
- }`, { where }, m.role, context.signal)).items?.[0] ?? null;
842
+ }`, { where }, m.role, context.signal, "query")).items?.[0] ?? null;
765
843
  } else item = (await gql(`query One_${m.table}($id: ${pkType}!) {
766
844
  item: ${root}(${pkField}: $id) { ${fields} }
767
- }`, { id: descriptor.id }, m.role, context.signal)).item;
845
+ }`, { id: descriptor.id }, m.role, context.signal, "query")).item;
768
846
  if (item == null) throw {
769
- kind: "protocol",
847
+ kind: "not-found",
770
848
  code: "record-not-found",
771
849
  message: `Not found: ${descriptor.resource}/${descriptor.id}`
772
850
  };
@@ -802,7 +880,7 @@ function createHasuraDataProvider(config) {
802
880
  const object = m.jsonbSpill ? partitionJsonbSpill(mainInput, m.jsonbSpill, m.selectFields) : mainInput;
803
881
  const data = await gql(`mutation Create_${m.table}($object: ${m.table}_insert_input!) {
804
882
  item: insert_${m.table}_one(object: $object) { ${fields} }
805
- }`, { object }, m.role, context.signal);
883
+ }`, { object }, m.role, context.signal, "mutation");
806
884
  const result = mutationRecord(m, descriptor.idField, data.item, descriptor.resource);
807
885
  if (m.dataBody && dataBody) await upsertDataBody(m.dataBody, result.id, dataBody, m.role, context.signal);
808
886
  return result;
@@ -831,7 +909,7 @@ function createHasuraDataProvider(config) {
831
909
  }`, {
832
910
  where,
833
911
  set
834
- }, m.role, context.signal);
912
+ }, m.role, context.signal, "mutation");
835
913
  if (ifUpdatedAt != null && ((data.result.affected_rows ?? 0) === 0 || (data.result.returning?.length ?? 0) === 0)) throw {
836
914
  kind: "conflict",
837
915
  code: "stale-record",
@@ -845,7 +923,7 @@ function createHasuraDataProvider(config) {
845
923
  }`, {
846
924
  id: descriptor.id,
847
925
  set
848
- }, m.role, context.signal)).item;
926
+ }, m.role, context.signal, "mutation")).item;
849
927
  }
850
928
  const result = mutationRecord(m, descriptor.idField, item, descriptor.resource, descriptor.id);
851
929
  if (m.dataBody && dataBody) await upsertDataBody(m.dataBody, result.id, dataBody, m.role, context.signal);
@@ -860,12 +938,12 @@ function createHasuraDataProvider(config) {
860
938
  const where = mergeWhere({ [pkField]: { _eq: descriptor.id } }, m.fixedFilter);
861
939
  item = (await gql(`mutation Delete_${m.table}($where: ${m.table}_bool_exp!) {
862
940
  result: delete_${m.table}(where: $where) { affected_rows returning { ${fields} } }
863
- }`, { where }, m.role, context.signal)).result.returning?.[0] ?? null;
941
+ }`, { where }, m.role, context.signal, "mutation")).result.returning?.[0] ?? null;
864
942
  } else {
865
943
  const pkType = m.pkType ?? "bigint";
866
944
  item = (await gql(`mutation Delete_${m.table}($id: ${pkType}!) {
867
945
  item: delete_${m.table}_by_pk(${pkField}: $id) { ${fields} }
868
- }`, { id: descriptor.id }, m.role, context.signal)).item;
946
+ }`, { id: descriptor.id }, m.role, context.signal, "mutation")).item;
869
947
  }
870
948
  mutationRecord(m, descriptor.idField, item, descriptor.resource, descriptor.id);
871
949
  return {
@@ -889,6 +967,7 @@ function createHasuraDataProvider(config) {
889
967
  exports.DEFAULT_HASURA_ENDPOINT = DEFAULT_HASURA_ENDPOINT;
890
968
  exports.FILTER_OPERATOR_UNSUPPORTED_CODE = FILTER_OPERATOR_UNSUPPORTED_CODE;
891
969
  exports.HASURA_CURSOR_CODES = HASURA_CURSOR_CODES;
970
+ exports.HASURA_EXTERNAL_FAILURE_CODES = HASURA_EXTERNAL_FAILURE_CODES;
892
971
  exports.LOCALE_REQUIRED_CODE = LOCALE_REQUIRED_CODE;
893
972
  exports.NOT_AUTHENTICATED_CODE = NOT_AUTHENTICATED_CODE;
894
973
  exports.aggregateRoot = aggregateRoot;