graphddb 0.2.3 → 0.2.4

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
@@ -77,8 +77,10 @@ class UserModel extends DDBModel {
77
77
  }
78
78
  const User = UserModel.asModel();
79
79
 
80
- // Wire up the DynamoDB client once (configure retries/region on the client itself).
81
- DDBModel.setClient(new DynamoDBClient({}));
80
+ // Wire up the DynamoDB client once. GraphDDB owns throttle/transient retry
81
+ // (see "Retry & throttling" below), so set the client to `maxAttempts: 1` to
82
+ // avoid retrying twice (SDK × library).
83
+ DDBModel.setClient(new DynamoDBClient({ maxAttempts: 1 }));
82
84
 
83
85
  // Read — only the selected fields appear in the result type.
84
86
  const user = await User.query({ userId: 'alice' }, { name: true });
@@ -95,6 +97,50 @@ await DDBModel.mutate({
95
97
  For cross-service contracts, the Python bridge, multi-fragment atomic mutations,
96
98
  and more, see the sections below and [`docs/spec.md`](./docs/spec.md).
97
99
 
100
+ ## Retry & throttling
101
+
102
+ GraphDDB retries throttle / transient errors on **every** single-item op
103
+ (`GetItem` / `Query` / `PutItem` / `UpdateItem` / `DeleteItem`), the relation
104
+ read fan-out, and `TransactWriteItems` — out of the box, with no configuration.
105
+ The default policy is exponential backoff (`min(1000, 50·2^(n-1))` ms) with full
106
+ jitter, capped at 10 attempts, retrying only throttle / 5xx / transient errors
107
+ (never `ValidationException` or a conditional-check failure). A cancelled
108
+ transaction is inspected per-reason: a `ConditionalCheckFailed` reason throws
109
+ immediately, a purely throttle-induced cancellation is retried.
110
+
111
+ > This is the library's own **error retry**, distinct from the partial-batch
112
+ > `UnprocessedKeys` / `UnprocessedItems` retry that `batchGet` / `batchWrite`
113
+ > already perform. Both use the same backoff ramp but are separate mechanisms.
114
+
115
+ **Set your client to `maxAttempts: 1`.** Now that the library owns retry, leaving
116
+ the AWS SDK at its default (`maxAttempts: 3`) retries *multiplicatively*
117
+ underneath GraphDDB. Make the library the single source of truth:
118
+
119
+ ```ts
120
+ DDBModel.setClient(new DynamoDBClient({ maxAttempts: 1 }));
121
+ ```
122
+
123
+ GraphDDB never mutates a client you pass in — this is on you to set.
124
+
125
+ Tune the policy globally, or per call:
126
+
127
+ ```ts
128
+ import { DDBModel } from 'graphddb';
129
+
130
+ // Global override (mirrors setClient / setTableMapping). `null` restores the default.
131
+ DDBModel.setRetryPolicy({
132
+ maxAttempts: 5,
133
+ jitter: 'full', // or 'none'
134
+ onRetry: ({ attempt, error, delayMs, operation }) => {
135
+ console.warn(`retry #${attempt} of ${operation} after ${delayMs}ms`, error);
136
+ },
137
+ });
138
+
139
+ // Per-call override on any op's options (`RetryPolicy | false`). `false` disables retry.
140
+ await User.putItem({ userId: 'alice', name: 'Alice' }, { retry: false });
141
+ await User.query({ userId: 'alice' }, { name: true }, { retry: { maxAttempts: 3 } });
142
+ ```
143
+
98
144
  ## User Permissions Example
99
145
 
100
146
  The [user-permissions](./examples/user-permissions/) example models users, groups, memberships, and permissions in a single DynamoDB table.
@@ -651,6 +651,189 @@ async function batchWriteChunkWithRetry(docClient, tableName, entries, toCommand
651
651
  }
652
652
  }
653
653
 
654
+ // src/executor/retry-policy.ts
655
+ var DEFAULT_MAX_ATTEMPTS = 10;
656
+ var DEFAULT_RETRY_POLICY = {
657
+ maxAttempts: DEFAULT_MAX_ATTEMPTS,
658
+ computeDelay: computeBackoffDelay,
659
+ jitter: "full",
660
+ isRetryable: isRetryableError
661
+ };
662
+ var THROTTLE_ERROR_NAMES = /* @__PURE__ */ new Set([
663
+ "ProvisionedThroughputExceededException",
664
+ "ThrottlingException",
665
+ "ThrottlingError",
666
+ "RequestLimitExceeded",
667
+ "RequestThrottled",
668
+ "RequestThrottledException",
669
+ "TooManyRequestsException"
670
+ ]);
671
+ var TRANSIENT_ERROR_NAMES = /* @__PURE__ */ new Set([
672
+ "InternalServerError",
673
+ "InternalServerErrorException",
674
+ "InternalFailure",
675
+ "ServiceUnavailable",
676
+ "ServiceUnavailableException"
677
+ ]);
678
+ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
679
+ "ValidationException",
680
+ "ConditionalCheckFailedException",
681
+ "ResourceNotFoundException",
682
+ "AccessDeniedException",
683
+ "ResourceInUseException",
684
+ "ItemCollectionSizeLimitExceededException"
685
+ ]);
686
+ function errorName(error) {
687
+ if (typeof error !== "object" || error === null) return void 0;
688
+ const e = error;
689
+ if (typeof e.name === "string") return e.name;
690
+ if (typeof e.__type === "string") {
691
+ const t = e.__type;
692
+ const hash = t.lastIndexOf("#");
693
+ return hash >= 0 ? t.slice(hash + 1) : t;
694
+ }
695
+ return void 0;
696
+ }
697
+ function httpStatusCode(error) {
698
+ if (typeof error !== "object" || error === null) return void 0;
699
+ const meta = error.$metadata;
700
+ const code = meta?.httpStatusCode;
701
+ return typeof code === "number" ? code : void 0;
702
+ }
703
+ function isRetryableError(error) {
704
+ const name = errorName(error);
705
+ if (name && NON_RETRYABLE_ERROR_NAMES.has(name)) return false;
706
+ if (name === "TransactionCanceledException") return false;
707
+ if (name && THROTTLE_ERROR_NAMES.has(name)) return true;
708
+ if (name && TRANSIENT_ERROR_NAMES.has(name)) return true;
709
+ if (typeof error === "object" && error !== null) {
710
+ const retryable = error.$retryable;
711
+ if (retryable != null && retryable !== false) return true;
712
+ }
713
+ const status = httpStatusCode(error);
714
+ if (status === 429 || status != null && status >= 500) return true;
715
+ return false;
716
+ }
717
+ var THROTTLE_CANCELLATION_CODES = /* @__PURE__ */ new Set([
718
+ "ThrottlingError",
719
+ "ProvisionedThroughputExceeded",
720
+ "TransactionConflict"
721
+ ]);
722
+ function isRetryableTransactionCancellation(error) {
723
+ if (typeof error !== "object" || error === null) return false;
724
+ const reasons = error.CancellationReasons;
725
+ if (!Array.isArray(reasons) || reasons.length === 0) return false;
726
+ let sawThrottle = false;
727
+ for (const reason of reasons) {
728
+ const code = reason && typeof reason === "object" ? reason.Code : void 0;
729
+ if (typeof code !== "string" || code === "None") {
730
+ continue;
731
+ }
732
+ if (code === "ConditionalCheckFailed") {
733
+ return false;
734
+ }
735
+ if (THROTTLE_CANCELLATION_CODES.has(code)) {
736
+ sawThrottle = true;
737
+ continue;
738
+ }
739
+ return false;
740
+ }
741
+ return sawThrottle;
742
+ }
743
+ function resolveRetryPolicy(override, global) {
744
+ if (override === false) return null;
745
+ const o = override ?? void 0;
746
+ const g = global ?? void 0;
747
+ const maxAttempts = o?.maxAttempts ?? g?.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts;
748
+ if (maxAttempts <= 1) return null;
749
+ return {
750
+ maxAttempts,
751
+ computeDelay: o?.computeDelay ?? g?.computeDelay ?? DEFAULT_RETRY_POLICY.computeDelay,
752
+ jitter: o?.jitter ?? g?.jitter ?? DEFAULT_RETRY_POLICY.jitter,
753
+ isRetryable: o?.isRetryable ?? g?.isRetryable ?? DEFAULT_RETRY_POLICY.isRetryable,
754
+ // onRetry has no DEFAULT (observability is opt-in): per-call wins, else global.
755
+ onRetry: o?.onRetry ?? g?.onRetry
756
+ };
757
+ }
758
+ function applyJitter(base, jitter) {
759
+ if (jitter === "none") return base;
760
+ return Math.random() * base;
761
+ }
762
+ function sleep2(ms) {
763
+ return new Promise((resolve) => setTimeout(resolve, ms));
764
+ }
765
+ async function runWithRetry(policy, operation, send, classify) {
766
+ if (policy === null) {
767
+ return send();
768
+ }
769
+ let attempt = 1;
770
+ for (; ; ) {
771
+ try {
772
+ return await send();
773
+ } catch (error) {
774
+ const forced = classify?.(error);
775
+ const retryable = forced ?? policy.isRetryable(error);
776
+ if (!retryable || attempt >= policy.maxAttempts) {
777
+ throw error;
778
+ }
779
+ const base = policy.computeDelay(attempt);
780
+ const delayMs = applyJitter(base, policy.jitter);
781
+ policy.onRetry?.({ attempt, error, delayMs, operation });
782
+ await sleep2(delayMs);
783
+ attempt += 1;
784
+ }
785
+ }
786
+ }
787
+
788
+ // src/executor/retrying-executor.ts
789
+ var RetryingExecutor = class {
790
+ /**
791
+ * @param inner the wrapped executor (the real {@link DynamoExecutor}).
792
+ * @param getGlobalPolicy reads the globally-configured policy (or `null` for the
793
+ * built-in default) at CALL time, so a `DDBModel.setRetryPolicy(...)` after the
794
+ * executor is constructed still takes effect.
795
+ */
796
+ constructor(inner, getGlobalPolicy) {
797
+ this.inner = inner;
798
+ this.getGlobalPolicy = getGlobalPolicy;
799
+ }
800
+ inner;
801
+ getGlobalPolicy;
802
+ async execute(operation, options) {
803
+ const policy = resolveRetryPolicy(options?.retry, this.getGlobalPolicy());
804
+ const kind = operation.type === "Query" ? "query" : operation.type === "BatchGetItem" ? "batchGet" : "get";
805
+ return runWithRetry(policy, kind, () => this.inner.execute(operation, options));
806
+ }
807
+ async batchGet(input, options) {
808
+ const policy = resolveRetryPolicy(options?.retry, this.getGlobalPolicy());
809
+ return runWithRetry(policy, "batchGet", () => this.inner.batchGet(input, options));
810
+ }
811
+ async put(input, options) {
812
+ const policy = resolveRetryPolicy(options?.retry, this.getGlobalPolicy());
813
+ return runWithRetry(policy, "put", () => this.inner.put(input, options));
814
+ }
815
+ async update(input, options) {
816
+ const policy = resolveRetryPolicy(options?.retry, this.getGlobalPolicy());
817
+ return runWithRetry(policy, "update", () => this.inner.update(input, options));
818
+ }
819
+ async delete(input, options) {
820
+ const policy = resolveRetryPolicy(options?.retry, this.getGlobalPolicy());
821
+ return runWithRetry(policy, "delete", () => this.inner.delete(input, options));
822
+ }
823
+ async batchWrite(tableName, items, options) {
824
+ return this.inner.batchWrite(tableName, items, options);
825
+ }
826
+ async transactWrite(items, options) {
827
+ const policy = resolveRetryPolicy(options?.retry, this.getGlobalPolicy());
828
+ return runWithRetry(
829
+ policy,
830
+ "transactWrite",
831
+ () => this.inner.transactWrite(items, options),
832
+ (error) => errorName(error) === "TransactionCanceledException" ? isRetryableTransactionCancellation(error) : void 0
833
+ );
834
+ }
835
+ };
836
+
654
837
  // src/client/ClientManager.ts
655
838
  var ClientManager = class {
656
839
  static client = null;
@@ -663,6 +846,13 @@ var ClientManager = class {
663
846
  */
664
847
  static executor = null;
665
848
  static defaultExecutor = null;
849
+ /**
850
+ * The globally-configured single-op retry policy (issue #111). `null` ⇒ the
851
+ * always-on built-in {@link DEFAULT_RETRY_POLICY} applies (retry is enabled out
852
+ * of the box). Set via {@link DDBModel.setRetryPolicy} / {@link setRetryPolicy};
853
+ * read at call time by the default {@link RetryingExecutor}.
854
+ */
855
+ static retryPolicy = null;
666
856
  static setClient(client) {
667
857
  this.client = client;
668
858
  this.documentClient = null;
@@ -697,23 +887,44 @@ var ClientManager = class {
697
887
  return this.executor;
698
888
  }
699
889
  if (!this.defaultExecutor) {
700
- this.defaultExecutor = new DynamoExecutor();
890
+ this.defaultExecutor = new RetryingExecutor(
891
+ new DynamoExecutor(),
892
+ () => this.retryPolicy
893
+ );
701
894
  }
702
895
  return this.defaultExecutor;
703
896
  }
704
- /** Inject a custom executor (test adapter / memory engine). */
897
+ /**
898
+ * Inject a custom executor (test adapter / memory engine). It is used AS-IS —
899
+ * not wrapped in a {@link RetryingExecutor} — so the in-memory adapter (which
900
+ * never throttles) bypasses the retry layer entirely (issue #111).
901
+ */
705
902
  static setExecutor(executor) {
706
903
  this.executor = executor;
707
904
  }
708
- /** Restore the default {@link DynamoExecutor}. */
905
+ /** Restore the default {@link RetryingExecutor}-wrapped {@link DynamoExecutor}. */
709
906
  static resetExecutor() {
710
907
  this.executor = null;
711
908
  }
909
+ /**
910
+ * Configure the global single-op retry policy (issue #111). Mirrors
911
+ * {@link setClient} / {@link setTableMapping}: a process-wide setting the default
912
+ * {@link RetryingExecutor} reads. `null` restores the always-on built-in default.
913
+ * A per-call `retry?:` override on an operation option bag takes precedence.
914
+ */
915
+ static setRetryPolicy(policy) {
916
+ this.retryPolicy = policy;
917
+ }
918
+ /** The globally-configured retry policy, or `null` for the built-in default. */
919
+ static getRetryPolicy() {
920
+ return this.retryPolicy;
921
+ }
712
922
  /** @internal — for testing only */
713
923
  static reset() {
714
924
  this.client = null;
715
925
  this.documentClient = null;
716
926
  this.executor = null;
927
+ this.retryPolicy = null;
717
928
  }
718
929
  };
719
930
 
@@ -742,7 +953,7 @@ async function executeBatchGet(docClient, operation) {
742
953
 
743
954
  // src/executor/dynamo-executor.ts
744
955
  var DynamoExecutor = class {
745
- async execute(operation) {
956
+ async execute(operation, _options) {
746
957
  const docClient = await ClientManager.getDocumentClient();
747
958
  if (operation.type === "GetItem") {
748
959
  return executeGetItem(docClient, operation);
@@ -752,7 +963,7 @@ var DynamoExecutor = class {
752
963
  }
753
964
  return executeQuery(docClient, operation);
754
965
  }
755
- async batchGet(input) {
966
+ async batchGet(input, _options) {
756
967
  const docClient = await ClientManager.getDocumentClient();
757
968
  return executeBatchGet(docClient, { type: "BatchGetItem", ...input });
758
969
  }
@@ -786,7 +997,7 @@ var DynamoExecutor = class {
786
997
  );
787
998
  return toWriteResult(result);
788
999
  }
789
- async batchWrite(tableName, items) {
1000
+ async batchWrite(tableName, items, _options) {
790
1001
  const docClient = await ClientManager.getDocumentClient();
791
1002
  for (const chunk of chunkArray(items, BATCH_WRITE_MAX_ITEMS)) {
792
1003
  await batchWriteChunkWithRetry(
@@ -798,7 +1009,7 @@ var DynamoExecutor = class {
798
1009
  );
799
1010
  }
800
1011
  }
801
- async transactWrite(items) {
1012
+ async transactWrite(items, _options) {
802
1013
  if (items.length === 0) return;
803
1014
  const docClient = await ClientManager.getDocumentClient();
804
1015
  const { TransactWriteCommand } = await loadLibDynamoDB();
@@ -1505,10 +1716,10 @@ function buildPutInput(modelClass, item, options) {
1505
1716
  async function executePut(modelClass, item, options) {
1506
1717
  const putInput = buildPutInput(modelClass, item, options);
1507
1718
  const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
1508
- const result = await ClientManager.getExecutor().put(
1509
- putInput,
1510
- wantsOld ? { returnOldImage: true } : void 0
1511
- );
1719
+ const result = await ClientManager.getExecutor().put(putInput, {
1720
+ ...wantsOld ? { returnOldImage: true } : {},
1721
+ ...options?.retry !== void 0 ? { retry: options.retry } : {}
1722
+ });
1512
1723
  if (ChangeCaptureRegistry.hasSubscribers()) {
1513
1724
  const oldItem = result.oldItem;
1514
1725
  captureWrite({
@@ -1800,12 +2011,15 @@ function needsReadForRederive(meta, entity, serializedChanges, fieldMap) {
1800
2011
  return affected.some((gsi2) => missingGsiFields(gsi2, available).length > 0);
1801
2012
  }
1802
2013
  async function rederiveByReadModifyWrite(modelClass, entity, changes, options, pk, sk, tableName) {
1803
- const result = await ClientManager.getExecutor().execute({
1804
- type: "GetItem",
1805
- tableName,
1806
- keyCondition: { PK: pk, SK: sk },
1807
- consistentRead: true
1808
- });
2014
+ const result = await ClientManager.getExecutor().execute(
2015
+ {
2016
+ type: "GetItem",
2017
+ tableName,
2018
+ keyCondition: { PK: pk, SK: sk },
2019
+ consistentRead: true
2020
+ },
2021
+ options?.retry !== void 0 ? { retry: options.retry } : void 0
2022
+ );
1809
2023
  const current = result.items[0];
1810
2024
  if (!current) {
1811
2025
  throw new Error(
@@ -1853,10 +2067,10 @@ async function executeUpdate(modelClass, entity, changes, options) {
1853
2067
  updateInput = buildUpdateInput(modelClass, entity, changes, options);
1854
2068
  }
1855
2069
  const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
1856
- const result = await ClientManager.getExecutor().update(
1857
- updateInput,
1858
- wantsOld ? { returnOldImage: true } : void 0
1859
- );
2070
+ const result = await ClientManager.getExecutor().update(updateInput, {
2071
+ ...wantsOld ? { returnOldImage: true } : {},
2072
+ ...options?.retry !== void 0 ? { retry: options.retry } : {}
2073
+ });
1860
2074
  if (ChangeCaptureRegistry.hasSubscribers()) {
1861
2075
  const oldItem = result.oldItem;
1862
2076
  const keys = { pk: String(updateInput.Key.PK), sk: String(updateInput.Key.SK ?? "") };
@@ -1924,10 +2138,10 @@ function buildDeleteInput(modelClass, keyObj, options) {
1924
2138
  async function executeDelete(modelClass, keyObj, options) {
1925
2139
  const deleteInput = buildDeleteInput(modelClass, keyObj, options);
1926
2140
  const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
1927
- const result = await ClientManager.getExecutor().delete(
1928
- deleteInput,
1929
- wantsOld ? { returnOldImage: true } : void 0
1930
- );
2141
+ const result = await ClientManager.getExecutor().delete(deleteInput, {
2142
+ ...wantsOld ? { returnOldImage: true } : {},
2143
+ ...options?.retry !== void 0 ? { retry: options.retry } : {}
2144
+ });
1931
2145
  if (ChangeCaptureRegistry.hasSubscribers()) {
1932
2146
  const oldItem = result.oldItem;
1933
2147
  captureWrite({
@@ -2002,7 +2216,10 @@ async function commitTransaction(items, options = {}) {
2002
2216
  `Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
2003
2217
  );
2004
2218
  }
2005
- await ClientManager.getExecutor().transactWrite(collapsed);
2219
+ await ClientManager.getExecutor().transactWrite(
2220
+ collapsed,
2221
+ options.retry !== void 0 ? { retry: options.retry } : void 0
2222
+ );
2006
2223
  captureTransactItems(collapsed, options.modelBySignature ?? EMPTY_MODEL_MAP);
2007
2224
  return collapsed;
2008
2225
  }
@@ -2259,6 +2476,11 @@ export {
2259
2476
  BATCH_GET_MAX_KEYS,
2260
2477
  BATCH_WRITE_MAX_ITEMS,
2261
2478
  DynamoExecutor,
2479
+ DEFAULT_MAX_ATTEMPTS,
2480
+ DEFAULT_RETRY_POLICY,
2481
+ isRetryableError,
2482
+ isRetryableTransactionCancellation,
2483
+ RetryingExecutor,
2262
2484
  ClientManager,
2263
2485
  isRawCondition,
2264
2486
  cond,
@@ -32,7 +32,7 @@ import {
32
32
  serializeFieldValue,
33
33
  serializeRawCondition,
34
34
  skTemplate
35
- } from "./chunk-FWDJSGF3.js";
35
+ } from "./chunk-GCPEUPAA.js";
36
36
 
37
37
  // src/spec/types.ts
38
38
  var SPEC_VERSION = "1.0";
@@ -686,8 +686,8 @@ function buildFromSegments(segmented, inputFieldNames, queryKey, pkAttr, skAttr)
686
686
  }
687
687
 
688
688
  // src/executor/executor.ts
689
- async function execute(operation) {
690
- return ClientManager.getExecutor().execute(operation);
689
+ async function execute(operation, options) {
690
+ return ClientManager.getExecutor().execute(operation, options);
691
691
  }
692
692
 
693
693
  // src/hydrator/hydrator.ts
@@ -810,7 +810,10 @@ async function executeListInternal(modelClass, key, selectSpec, options = {}) {
810
810
  updatable: options.updatable ?? false
811
811
  });
812
812
  const operation = executionPlan.operations[0];
813
- const result = await execute(operation);
813
+ const result = await execute(
814
+ operation,
815
+ options.retry !== void 0 ? { retry: options.retry } : void 0
816
+ );
814
817
  const rawItems = result.items;
815
818
  const hydrated = hydrate(rawItems, select, metadata, options.updatable ?? false);
816
819
  const cursor = result.lastEvaluatedKey ? encodeCursor(result.lastEvaluatedKey) : null;
@@ -1172,7 +1175,10 @@ async function fetchBelongsToHasOneBatch(rel, rawParentItems, selectSpec, target
1172
1175
  const executor = ClientManager.getExecutor();
1173
1176
  const queryKeyToRaw = /* @__PURE__ */ new Map();
1174
1177
  for (const operation of batchOps) {
1175
- const batchResult = await executor.batchGet(operation);
1178
+ const batchResult = await executor.batchGet(
1179
+ operation,
1180
+ options.retry !== void 0 ? { retry: options.retry } : void 0
1181
+ );
1176
1182
  for (const item of batchResult.items) {
1177
1183
  for (const queryKey of queryKeys) {
1178
1184
  if (!matchesQueryKey(item, queryKey)) {
@@ -1272,7 +1278,8 @@ async function resolveRelations(parentItem, rawParentItem, select, metadata, opt
1272
1278
  after: selectSpec.after,
1273
1279
  order,
1274
1280
  filter: selectSpec.filter,
1275
- updatable: opts.updatable
1281
+ updatable: opts.updatable,
1282
+ retry: opts.retry
1276
1283
  });
1277
1284
  let items = listResult.items;
1278
1285
  const rawItems = listResult.rawItems;
@@ -1345,8 +1352,12 @@ async function executeQuery(modelClass, key, selectSpec, options) {
1345
1352
  (sourceField) => key[sourceField] != null
1346
1353
  )
1347
1354
  );
1348
- const parentPromise = execute(operation);
1349
- const relationPromise = canParallelizeRelations ? resolveRelations({}, key, select, metadata, { maxDepth, updatable }, 1) : null;
1355
+ const retry = options?.retry;
1356
+ const parentPromise = execute(
1357
+ operation,
1358
+ retry !== void 0 ? { retry } : void 0
1359
+ );
1360
+ const relationPromise = canParallelizeRelations ? resolveRelations({}, key, select, metadata, { maxDepth, updatable, retry }, 1) : null;
1350
1361
  let result;
1351
1362
  try {
1352
1363
  result = await parentPromise;
@@ -1373,7 +1384,7 @@ async function executeQuery(modelClass, key, selectSpec, options) {
1373
1384
  rawItem,
1374
1385
  select,
1375
1386
  metadata,
1376
- { maxDepth, updatable },
1387
+ { maxDepth, updatable, retry },
1377
1388
  1
1378
1389
  );
1379
1390
  if (hydratedItem && updatable) {
@@ -1894,8 +1905,11 @@ function renderWrite(op, key, params, contextLabel) {
1894
1905
  }
1895
1906
  return base;
1896
1907
  }
1897
- async function executeSingleWrite(w) {
1898
- const options = w.condition !== void 0 ? { condition: w.condition } : void 0;
1908
+ async function executeSingleWrite(w, retry) {
1909
+ const options = w.condition !== void 0 || retry !== void 0 ? {
1910
+ ...w.condition !== void 0 ? { condition: w.condition } : {},
1911
+ ...retry !== void 0 ? { retry } : {}
1912
+ } : void 0;
1899
1913
  if (w.operation === "put") {
1900
1914
  await executePut(w.modelClass, w.item ?? {}, options);
1901
1915
  return;
@@ -2062,7 +2076,7 @@ function renderIdempotencyGuardItems(guard, key, params, contextLabel) {
2062
2076
  return { Put: put };
2063
2077
  });
2064
2078
  }
2065
- async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map()) {
2079
+ async function executeReferentialTransaction(writes, edgeItems, updateItems, guardItemsRaw, outboxItems, idempotencyItems, checks, methodName, modelBySignature = /* @__PURE__ */ new Map(), retry) {
2066
2080
  const writeItems = writes.map((w) => {
2067
2081
  const options = w.condition !== void 0 ? { condition: w.condition } : void 0;
2068
2082
  let item;
@@ -2089,6 +2103,7 @@ async function executeReferentialTransaction(writes, edgeItems, updateItems, gua
2089
2103
  ],
2090
2104
  {
2091
2105
  modelBySignature,
2106
+ ...retry !== void 0 ? { retry } : {},
2092
2107
  limitError: (count) => new Error(
2093
2108
  `Contract runtime: command method '${methodName}' composes ${count} items (writes + edges + derived updates + uniqueness guards + outbox events + idempotency guard + ConditionChecks) into one TransactWriteItems, but DynamoDB caps a transaction at ${MAX_TRANSACT_ITEMS} items and a transaction is atomic \u2014 it cannot be split. Reduce the fragment / effect count.`
2094
2109
  )
@@ -2322,7 +2337,7 @@ async function executeCompiledWriteOp(ops, options) {
2322
2337
  }
2323
2338
  const hasDerivedEffects = renderedOps.some((r) => r.hasDerivedEffects);
2324
2339
  if (rendered.length === 1 && !hasDerivedEffects) {
2325
- await executeSingleWrite(rendered[0]);
2340
+ await executeSingleWrite(rendered[0], options.retry);
2326
2341
  } else {
2327
2342
  await executeReferentialTransaction(
2328
2343
  rendered,
@@ -2333,7 +2348,8 @@ async function executeCompiledWriteOp(ops, options) {
2333
2348
  idempotencyItems,
2334
2349
  checkItems,
2335
2350
  label,
2336
- modelBySignature
2351
+ modelBySignature,
2352
+ options.retry
2337
2353
  );
2338
2354
  }
2339
2355
  const readBacks = await Promise.all(
@@ -2380,7 +2396,8 @@ async function executeCompiledParallelWrites(ops, options) {
2380
2396
  ...individualIdx.map(async (i) => {
2381
2397
  try {
2382
2398
  const { readBacks } = await executeCompiledWriteOp([ops[i]], {
2383
- label: `${label} (op #${i})`
2399
+ label: `${label} (op #${i})`,
2400
+ ...options.retry !== void 0 ? { retry: options.retry } : {}
2384
2401
  });
2385
2402
  results[i] = { ok: true, value: readBacks[0] };
2386
2403
  } catch (e) {
@@ -2459,9 +2476,9 @@ async function executeWriteEnvelope(envelope, options = {}) {
2459
2476
  }
2460
2477
  const ops = aliases.flatMap((alias) => normalizeWriteOp(alias, envelope[alias]));
2461
2478
  if (mode === "transaction") {
2462
- return executeTransactionMode(ops);
2479
+ return executeTransactionMode(ops, options.retry);
2463
2480
  }
2464
- return executeParallelMode(ops);
2481
+ return executeParallelMode(ops, options.retry);
2465
2482
  }
2466
2483
  function normalizeWriteOp(alias, d) {
2467
2484
  if (d === null || typeof d !== "object") {
@@ -2534,10 +2551,11 @@ function mapToRefs($, fields) {
2534
2551
  for (const f of fields) out[f] = $[f];
2535
2552
  return out;
2536
2553
  }
2537
- async function executeTransactionMode(ops) {
2554
+ async function executeTransactionMode(ops, retry) {
2538
2555
  const compiled = ops.map(compileWriteOp);
2539
2556
  const { readBacks } = await executeCompiledWriteOp(compiled, {
2540
- label: "DDBModel.mutate"
2557
+ label: "DDBModel.mutate",
2558
+ ...retry !== void 0 ? { retry } : {}
2541
2559
  });
2542
2560
  const out = {};
2543
2561
  ops.forEach((op, i) => {
@@ -2552,10 +2570,11 @@ async function executeTransactionMode(ops) {
2552
2570
  });
2553
2571
  return out;
2554
2572
  }
2555
- async function executeParallelMode(ops) {
2573
+ async function executeParallelMode(ops, retry) {
2556
2574
  const compiled = ops.map(compileWriteOp);
2557
2575
  const settled = await executeCompiledParallelWrites(compiled, {
2558
- label: "DDBModel.mutate"
2576
+ label: "DDBModel.mutate",
2577
+ ...retry !== void 0 ? { retry } : {}
2559
2578
  });
2560
2579
  const out = {};
2561
2580
  ops.forEach((op, i) => {
@@ -2587,12 +2606,34 @@ function resolveModelStatic(ref, alias) {
2587
2606
 
2588
2607
  // src/model/DDBModel.ts
2589
2608
  var DDBModel = class {
2609
+ /**
2610
+ * Register the `DynamoDBClient` GraphDDB sends through.
2611
+ *
2612
+ * IMPORTANT (issue #111): GraphDDB now owns single-op throttle / transient-error
2613
+ * retry (see {@link setRetryPolicy}), so a client left at the AWS SDK default
2614
+ * `maxAttempts: 3` retries MULTIPLICATIVELY underneath the library. Construct the
2615
+ * client with `maxAttempts: 1` so the library is the single source of truth for
2616
+ * retry — e.g. `new DynamoDBClient({ maxAttempts: 1 })`. GraphDDB does NOT mutate
2617
+ * a client you pass in.
2618
+ */
2590
2619
  static setClient(client) {
2591
2620
  ClientManager.setClient(client);
2592
2621
  }
2593
2622
  static setTableMapping(mapping) {
2594
2623
  TableMapping.set(mapping);
2595
2624
  }
2625
+ /**
2626
+ * Configure the global single-op retry policy (issue #111) — exponential backoff
2627
+ * + jitter with a bounded attempt cap, applied to every GetItem / Query / Put /
2628
+ * Update / Delete / relation-fan-out send and to `TransactWriteItems`. Mirrors
2629
+ * {@link setClient} / {@link setTableMapping}. A sensible default is ALWAYS on; call
2630
+ * this only to tune it (or pass `null` to restore the default). A per-call
2631
+ * `retry?: RetryPolicy | false` on an operation's options overrides this globally
2632
+ * for that call (`false` disables retry).
2633
+ */
2634
+ static setRetryPolicy(policy) {
2635
+ ClientManager.setRetryPolicy(policy);
2636
+ }
2596
2637
  static async transaction(fn) {
2597
2638
  await executeTransaction(fn);
2598
2639
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ChangeCaptureRegistry,
3
3
  resolveModelClass
4
- } from "./chunk-FWDJSGF3.js";
4
+ } from "./chunk-GCPEUPAA.js";
5
5
 
6
6
  // src/cdc/prng.ts
7
7
  var SeededRandom = class {
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  buildBridgeBundle,
4
4
  normalizeSelectSpec
5
- } from "./chunk-NJEC76TX.js";
5
+ } from "./chunk-IQEOJVHI.js";
6
6
  import {
7
7
  MetadataRegistry
8
- } from "./chunk-FWDJSGF3.js";
8
+ } from "./chunk-GCPEUPAA.js";
9
9
 
10
10
  // src/cli.ts
11
11
  import { createRequire } from "module";