graphddb 0.2.2 → 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 +48 -2
- package/dist/{chunk-3MI43FHU.js → chunk-GCPEUPAA.js} +250 -28
- package/dist/{chunk-CXIAECRS.js → chunk-IQEOJVHI.js} +62 -21
- package/dist/{chunk-27XBTYVK.js → chunk-LNESVUTO.js} +1 -1
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +105 -12
- package/dist/index.js +13 -3
- package/dist/testing/index.d.ts +5 -5
- package/dist/testing/index.js +9 -6
- package/dist/{types-SKxZQbQO.d.ts → types-BEeQ_nSd.d.ts} +242 -41
- package/package.json +1 -1
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
|
|
81
|
-
|
|
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
|
|
890
|
+
this.defaultExecutor = new RetryingExecutor(
|
|
891
|
+
new DynamoExecutor(),
|
|
892
|
+
() => this.retryPolicy
|
|
893
|
+
);
|
|
701
894
|
}
|
|
702
895
|
return this.defaultExecutor;
|
|
703
896
|
}
|
|
704
|
-
/**
|
|
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
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
1858
|
-
|
|
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
|
-
|
|
1929
|
-
|
|
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({
|
|
@@ -1954,11 +2168,11 @@ function collapseSameKey(items, view) {
|
|
|
1954
2168
|
for (const bucket of groups.values()) {
|
|
1955
2169
|
if (bucket.length === 1) continue;
|
|
1956
2170
|
const kinds = new Set(bucket.map((it) => view.kind(it)));
|
|
1957
|
-
if (kinds.has("Delete") && kinds.has("Put")) {
|
|
2171
|
+
if (kinds.size === 2 && kinds.has("Delete") && kinds.has("Put")) {
|
|
1958
2172
|
for (const it of bucket) dropped.add(it);
|
|
1959
2173
|
continue;
|
|
1960
2174
|
}
|
|
1961
|
-
if (kinds.has("Put") && kinds.has("Update")) {
|
|
2175
|
+
if (kinds.size === 2 && kinds.has("Put") && kinds.has("Update")) {
|
|
1962
2176
|
throw new Error(
|
|
1963
2177
|
`Contract runtime: a derived counter (\`increment\`) resolves to the SAME item as the entity write at runtime (the call-time key values made an aliased target coincide with the written row). A Put+Update on one transaction item is unsupported \u2014 DynamoDB rejects touching one key twice, and collapsing it would silently drop the increment. A \`w.increment(...)\` must target a DIFFERENT row (e.g. a parent aggregate's counter); to maintain a counter ON the written entity, write the field as part of the entity itself, not via a derived counter onto its own row.`
|
|
1964
2178
|
);
|
|
@@ -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(
|
|
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-
|
|
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(
|
|
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(
|
|
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
|
|
1349
|
-
const
|
|
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
|
|
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
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import {
|
|
3
3
|
buildBridgeBundle,
|
|
4
4
|
normalizeSelectSpec
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-IQEOJVHI.js";
|
|
6
6
|
import {
|
|
7
7
|
MetadataRegistry
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-GCPEUPAA.js";
|
|
9
9
|
|
|
10
10
|
// src/cli.ts
|
|
11
11
|
import { createRequire } from "module";
|