lambder 3.4.2 → 3.5.2

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.
@@ -3,6 +3,7 @@ export default class LambderCaller {
3
3
  isCorsEnabled;
4
4
  apiPath;
5
5
  apiVersion;
6
+ timeoutMs;
6
7
  fetchTrackerList = [];
7
8
  isLoading = false;
8
9
  versionExpiredHandler;
@@ -17,10 +18,11 @@ export default class LambderCaller {
17
18
  sessionTokenCookieKey = "LMDRSESSIONTKID";
18
19
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
19
20
  sessionCookieDomain;
20
- constructor({ apiPath, apiVersion, isCorsEnabled = false, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }) {
21
+ constructor({ apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }) {
21
22
  this.apiPath = apiPath ?? "/api";
22
23
  this.apiVersion = apiVersion;
23
24
  this.isCorsEnabled = isCorsEnabled;
25
+ this.timeoutMs = timeoutMs;
24
26
  this.sessionCookieDomain = sessionCookieDomain;
25
27
  this.versionExpiredHandler = versionExpiredHandler;
26
28
  this.sessionExpiredHandler = sessionExpiredHandler;
@@ -37,6 +39,32 @@ export default class LambderCaller {
37
39
  this.sessionTokenCookieKey = sessionTokenCookieKey;
38
40
  this.sessionCsrfCookieKey = sessionCsrfCookieKey;
39
41
  }
42
+ /**
43
+ * Generate an idempotency key for one logical operation. Create it when
44
+ * the operation begins (a form opens, a draft starts), send the same key
45
+ * on every attempt of that operation, and generate a new one after a
46
+ * confirmed success. Uses crypto.randomUUID when available and falls back
47
+ * to a v4 UUID from getRandomValues, because randomUUID only exists in
48
+ * secure contexts (plain-http LAN device testing lacks it).
49
+ */
50
+ static createIdempotencyKey() {
51
+ const cryptoObj = globalThis.crypto;
52
+ if (cryptoObj?.randomUUID)
53
+ return cryptoObj.randomUUID();
54
+ const bytes = new Uint8Array(16);
55
+ if (cryptoObj?.getRandomValues) {
56
+ cryptoObj.getRandomValues(bytes);
57
+ }
58
+ else {
59
+ for (let i = 0; i < 16; i += 1) {
60
+ bytes[i] = Math.floor(Math.random() * 256);
61
+ }
62
+ }
63
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
64
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
65
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
66
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
67
+ }
40
68
  clearSessionCookies() {
41
69
  const domainOption = this.sessionCookieDomain;
42
70
  const hostname = typeof window !== "undefined" ? window.location.hostname : "";
@@ -48,117 +76,227 @@ export default class LambderCaller {
48
76
  Cookies.remove(key, { domain: resolvedDomain, path: "/" });
49
77
  }
50
78
  }
51
- async apiRaw(apiName, payload, options) {
79
+ /** One call, one outcome. Never throws; every failure path resolves to { ok: false }. */
80
+ async dispatch(apiName, payload, options) {
81
+ // Per-call overrides win over the constructor handlers.
82
+ const versionExpiredHandler = options?.versionExpiredHandler ?? this.versionExpiredHandler;
83
+ const sessionExpiredHandler = options?.sessionExpiredHandler ?? this.sessionExpiredHandler;
84
+ const messageHandler = options?.messageHandler ?? this.messageHandler;
85
+ const errorMessageHandler = options?.errorMessageHandler ?? this.errorMessageHandler;
86
+ const notAuthorizedHandler = options?.notAuthorizedHandler ?? this.notAuthorizedHandler;
87
+ const errorHandler = options?.errorHandler ?? this.errorHandler;
88
+ const apiInputValidationErrorHandler = options?.apiInputValidationErrorHandler ?? this.apiInputValidationErrorHandler;
89
+ const fetchStartedHandler = options?.fetchStartedHandler ?? this.fetchStartedHandler;
90
+ const fetchEndedHandler = options?.fetchEndedHandler ?? this.fetchEndedHandler;
52
91
  const headers = options?.headers;
53
92
  const fetchTracker = { apiName, done: false, fetchEndCalled: false };
93
+ const fetchEnded = async (fetchResult) => {
94
+ fetchTracker.done = true;
95
+ if (fetchTracker.fetchEndCalled || !fetchEndedHandler)
96
+ return;
97
+ fetchTracker.fetchEndCalled = true;
98
+ await fetchEndedHandler({
99
+ fetchParams: { apiName, payload, headers },
100
+ fetchResult,
101
+ activeFetchList: this.fetchTrackerList.filter(v => !v.done),
102
+ });
103
+ };
104
+ let errorHandlerCalled = false;
105
+ const reportError = async (err) => {
106
+ if (errorHandlerCalled || !errorHandler)
107
+ return;
108
+ errorHandlerCalled = true;
109
+ await errorHandler(err);
110
+ };
111
+ // Timeout / abort wiring: the timeout gets its own controller chained
112
+ // to any external signal, so either source aborts the fetch.
113
+ const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
114
+ const externalSignal = options?.signal;
115
+ let timedOut = false;
116
+ let signal = externalSignal;
117
+ let timeoutId;
118
+ if (timeoutMs !== undefined) {
119
+ const controller = new AbortController();
120
+ if (externalSignal) {
121
+ if (externalSignal.aborted) {
122
+ controller.abort(externalSignal.reason);
123
+ }
124
+ else {
125
+ externalSignal.addEventListener("abort", () => controller.abort(externalSignal.reason), { once: true });
126
+ }
127
+ }
128
+ timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
129
+ signal = controller.signal;
130
+ }
54
131
  try {
55
132
  this.fetchTrackerList.push(fetchTracker);
56
- if (this.fetchStartedHandler)
57
- await this.fetchStartedHandler({
133
+ if (fetchStartedHandler)
134
+ await fetchStartedHandler({
58
135
  fetchParams: { apiName, payload, headers, },
59
136
  activeFetchList: this.fetchTrackerList.filter(v => !v.done)
60
137
  });
61
138
  const version = this.apiVersion;
62
139
  const token = Cookies.get(this.sessionCsrfCookieKey) || "";
63
140
  const siteHost = window.location.hostname;
64
- let data = await fetch(this.apiPath, {
65
- method: 'POST', cache: 'no-cache',
66
- // Cross-origin API hosts need CORS mode and included credentials.
67
- mode: this.isCorsEnabled ? 'cors' : 'same-origin',
68
- credentials: this.isCorsEnabled ? 'include' : 'same-origin',
69
- redirect: 'follow', referrerPolicy: 'origin',
70
- headers: { 'Content-Type': 'application/json', ...(headers || {}) },
71
- body: JSON.stringify({ apiName, version, token, siteHost, payload, }),
72
- }).then(async (res) => {
73
- if (res.status >= 500)
74
- throw new Error("Request failed: " + res.status + " - " + res.statusText);
75
- if (res.status === 422) {
76
- const errorData = await res.json();
77
- if (this.apiInputValidationErrorHandler) {
78
- await this.apiInputValidationErrorHandler(errorData.zodError);
79
- }
80
- else if (this.errorHandler) {
81
- await this.errorHandler(new Error("API Input Validation Error", { cause: errorData.zodError }));
141
+ let res;
142
+ try {
143
+ res = await fetch(this.apiPath, {
144
+ method: 'POST', cache: 'no-cache',
145
+ // Cross-origin API hosts need CORS mode and included credentials.
146
+ mode: this.isCorsEnabled ? 'cors' : 'same-origin',
147
+ credentials: this.isCorsEnabled ? 'include' : 'same-origin',
148
+ redirect: 'follow', referrerPolicy: 'origin',
149
+ headers: { 'Content-Type': 'application/json', ...(headers || {}) },
150
+ body: JSON.stringify({
151
+ apiName, version, token, siteHost, payload,
152
+ ...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
153
+ }),
154
+ ...(signal ? { signal } : {}),
155
+ });
156
+ }
157
+ catch (err) {
158
+ const wrappedError = err instanceof Error ? err : new Error("Request failed", { cause: err });
159
+ await fetchEnded(wrappedError);
160
+ await reportError(wrappedError);
161
+ return { ok: false, reason: timedOut ? 'timeout' : 'network', error: wrappedError };
162
+ }
163
+ if (res.status >= 500) {
164
+ // Lambder's own 500 fallback is a JSON envelope, but custom
165
+ // error handlers may answer text/HTML: parse defensively.
166
+ let errorMessage;
167
+ try {
168
+ const bodyText = await res.text();
169
+ try {
170
+ errorMessage = JSON.parse(bodyText)?.errorMessage;
82
171
  }
83
- return null;
172
+ catch { /* not an envelope */ }
173
+ }
174
+ catch { /* body unavailable */ }
175
+ const wrappedError = new Error("Request failed: " + res.status + " - " + res.statusText);
176
+ await fetchEnded(wrappedError);
177
+ await reportError(wrappedError);
178
+ return { ok: false, reason: 'server', status: res.status, errorMessage, error: wrappedError };
179
+ }
180
+ if (res.status === 422) {
181
+ // A 422 without Lambder's validation body (e.g. a proxy's
182
+ // error page) is a server failure, not a validation result.
183
+ let zodError;
184
+ try {
185
+ zodError = (await res.json())?.zodError;
84
186
  }
85
- ;
86
- if (res.headers.get("Content-Type")?.includes("application/lambder-json-stream")) {
87
- const decompressed = res.json();
88
- return decompressed;
187
+ catch { /* not JSON */ }
188
+ if (zodError === undefined) {
189
+ const wrappedError = new Error("Request failed: 422 without a validation body");
190
+ await fetchEnded(wrappedError);
191
+ await reportError(wrappedError);
192
+ return { ok: false, reason: 'server', status: res.status, error: wrappedError };
193
+ }
194
+ await fetchEnded(null);
195
+ if (apiInputValidationErrorHandler) {
196
+ await apiInputValidationErrorHandler(zodError);
89
197
  }
90
198
  else {
91
- return res.json();
199
+ await reportError(new Error("API Input Validation Error", { cause: zodError }));
92
200
  }
93
- });
94
- fetchTracker.done = true;
95
- if (this.fetchEndedHandler) {
96
- fetchTracker.fetchEndCalled = true;
97
- await this.fetchEndedHandler({
98
- fetchParams: { apiName, payload, headers },
99
- fetchResult: data,
100
- activeFetchList: this.fetchTrackerList.filter(v => !v.done),
101
- });
201
+ return { ok: false, reason: 'validation', status: res.status, zodError };
202
+ }
203
+ let data;
204
+ try {
205
+ data = await res.json();
206
+ if (data === null || typeof data !== "object")
207
+ throw new Error("Response is not an object");
102
208
  }
103
- if (data && data.logList) {
209
+ catch (err) {
210
+ // A non-envelope body (e.g. an HTML error page) is a server failure.
211
+ const wrappedError = new Error("Request failed: response is not a valid API envelope (status " + res.status + ")", { cause: err });
212
+ await fetchEnded(wrappedError);
213
+ await reportError(wrappedError);
214
+ return { ok: false, reason: 'server', status: res.status, error: wrappedError };
215
+ }
216
+ await fetchEnded(data);
217
+ if (data.logList?.length) {
104
218
  for (const record of data.logList) {
105
- // Log to API response for debugging
219
+ console.log("[lambder]", record);
106
220
  }
107
221
  }
108
- if (data && data.versionExpired) {
109
- if (this.versionExpiredHandler) {
110
- await this.versionExpiredHandler();
222
+ if (data.versionExpired) {
223
+ if (versionExpiredHandler) {
224
+ await versionExpiredHandler();
111
225
  }
112
- else if (this.errorHandler) {
113
- await this.errorHandler(new Error("Version Expired; Please refresh;"));
226
+ else {
227
+ await reportError(new Error("Version Expired; Please refresh;"));
114
228
  }
115
- return null;
229
+ return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
116
230
  }
117
- if (data && data.sessionExpired) {
231
+ if (data.sessionExpired) {
118
232
  this.clearSessionCookies();
119
- if (this.sessionExpiredHandler) {
120
- await this.sessionExpiredHandler();
233
+ if (sessionExpiredHandler) {
234
+ await sessionExpiredHandler();
121
235
  }
122
- else if (this.errorHandler) {
123
- await this.errorHandler(new Error("Session Expired; Please log in again;"));
236
+ else {
237
+ await reportError(new Error("Session Expired; Please log in again;"));
124
238
  }
125
- return null;
239
+ return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
126
240
  }
127
- if (data && data.notAuthorized) {
128
- if (this.notAuthorizedHandler) {
129
- await this.notAuthorizedHandler();
241
+ if (data.notAuthorized) {
242
+ if (notAuthorizedHandler) {
243
+ await notAuthorizedHandler();
130
244
  }
131
- else if (this.errorHandler) {
132
- await this.errorHandler(new Error("Not Authorized;"));
245
+ else {
246
+ await reportError(new Error("Not Authorized;"));
133
247
  }
134
- return null;
248
+ return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data };
135
249
  }
136
- if (data && data.message && this.messageHandler) {
137
- await this.messageHandler(data.message);
250
+ if (data.message && messageHandler) {
251
+ await messageHandler(data.message);
138
252
  }
139
- if (data && data.errorMessage && this.errorMessageHandler) {
140
- await this.errorMessageHandler(data.errorMessage);
253
+ if (data.errorMessage) {
254
+ if (errorMessageHandler) {
255
+ await errorMessageHandler(data.errorMessage);
256
+ }
257
+ return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data };
141
258
  }
142
- return data;
259
+ return { ok: true, payload: data.payload, response: data };
143
260
  }
144
261
  catch (err) {
262
+ // Escape hatch for anything above (typically an app handler throwing):
263
+ // dispatch never throws, so api()/apiOutcome() call sites never do.
145
264
  const wrappedError = err instanceof Error ? err : new Error("Error: ", { cause: err });
146
- fetchTracker.done = true;
147
- if (!fetchTracker.fetchEndCalled && this.fetchEndedHandler) {
148
- await this.fetchEndedHandler({
149
- fetchParams: { apiName, payload, headers, },
150
- fetchResult: wrappedError,
151
- activeFetchList: this.fetchTrackerList.filter(v => !v.done)
152
- });
153
- }
154
- if (this.errorHandler) {
155
- this.errorHandler(wrappedError);
265
+ try {
266
+ await fetchEnded(wrappedError);
267
+ await reportError(wrappedError);
156
268
  }
157
- return null;
269
+ catch { /* an app handler threw again; never propagate */ }
270
+ return { ok: false, reason: 'unknown', error: wrappedError };
271
+ }
272
+ finally {
273
+ fetchTracker.done = true;
274
+ if (timeoutId !== undefined)
275
+ clearTimeout(timeoutId);
158
276
  }
159
277
  }
160
278
  ;
161
- // Use the same type for api but adjust the return type
279
+ /**
280
+ * Full-fidelity call: resolves to a discriminated LambderApiOutcome
281
+ * instead of collapsing every failure to null. Never throws.
282
+ */
283
+ async apiOutcome(apiName, payload, options) {
284
+ return await this.dispatch(apiName, payload, options);
285
+ }
286
+ ;
287
+ /**
288
+ * Legacy shape: the parsed envelope on success (and on structured
289
+ * errorMessage refusals, which carry an envelope), null on every other
290
+ * failure. Prefer apiOutcome() when the call site needs to know why.
291
+ */
292
+ async apiRaw(apiName, payload, options) {
293
+ const outcome = await this.dispatch(apiName, payload, options);
294
+ if (outcome.ok)
295
+ return outcome.response;
296
+ return outcome.reason === 'errorMessage' ? outcome.response : null;
297
+ }
298
+ ;
299
+ /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
162
300
  async api(apiName, payload, options) {
163
301
  const result = await this.apiRaw(apiName, payload, options);
164
302
  return result?.payload;
@@ -2,6 +2,8 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
2
  export interface LambderDdbCacheOptions {
3
3
  tableName: string;
4
4
  region?: string;
5
+ /** Partition key prefix, keeps cache items separated from other systems in a shared table. Default: "CACHE". */
6
+ keyPrefix?: string;
5
7
  namespace?: string;
6
8
  defaultTtlSeconds?: number;
7
9
  chunkBytes?: number;
@@ -26,9 +28,15 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
26
28
  * chunk succeeds, so readers see either the previous complete version or the
27
29
  * new complete version. DynamoDB TTL is cleanup only; every read also checks
28
30
  * expiresAt because TTL deletion can lag.
31
+ *
32
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on
33
+ * `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
34
+ * table can be shared with LambderDdbRateLimiter (`RL#`) and
35
+ * LambderDdbIdempotency (`IDEM#`) without key collisions.
29
36
  */
30
37
  export declare class LambderDdbCache {
31
38
  readonly tableName: string;
39
+ readonly keyPrefix: string;
32
40
  readonly namespace: string;
33
41
  private readonly client;
34
42
  private readonly defaultTtlSeconds;
@@ -76,9 +76,15 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
76
76
  * chunk succeeds, so readers see either the previous complete version or the
77
77
  * new complete version. DynamoDB TTL is cleanup only; every read also checks
78
78
  * expiresAt because TTL deletion can lag.
79
+ *
80
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on
81
+ * `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
82
+ * table can be shared with LambderDdbRateLimiter (`RL#`) and
83
+ * LambderDdbIdempotency (`IDEM#`) without key collisions.
79
84
  */
80
85
  export class LambderDdbCache {
81
86
  tableName;
87
+ keyPrefix;
82
88
  namespace;
83
89
  client;
84
90
  defaultTtlSeconds;
@@ -91,6 +97,7 @@ export class LambderDdbCache {
91
97
  if (!options.tableName.trim())
92
98
  throw new Error("tableName is required");
93
99
  this.tableName = options.tableName;
100
+ this.keyPrefix = options.keyPrefix ?? "CACHE";
94
101
  this.namespace = options.namespace?.trim() || "default";
95
102
  if (Buffer.byteLength(this.namespace, "utf8") > 128) {
96
103
  throw new Error("namespace must be at most 128 UTF-8 bytes");
@@ -492,7 +499,7 @@ export class LambderDdbCache {
492
499
  return key;
493
500
  }
494
501
  async partitionKey(key) {
495
- return `${this.namespace}#${await sha256(key)}`;
502
+ return `${this.keyPrefix}#${this.namespace}#${await sha256(key)}`;
496
503
  }
497
504
  chunkSortKey(version, index) {
498
505
  return `chunk#${version}#${String(index).padStart(6, "0")}`;
@@ -0,0 +1,72 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ export interface LambderDdbIdempotencyOptions {
3
+ tableName: string;
4
+ region?: string;
5
+ /** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
6
+ keyPrefix?: string;
7
+ client?: DynamoDBClient;
8
+ }
9
+ export type LambderIdempotencyBeginResult = {
10
+ state: "new";
11
+ ownerToken: string;
12
+ } | {
13
+ state: "pending";
14
+ } | {
15
+ state: "done";
16
+ statusCode: number;
17
+ contentType: string | null;
18
+ body: string;
19
+ };
20
+ /**
21
+ * DynamoDB-backed idempotency records: one item per (identity, api, key)
22
+ * scope, claimed atomically with a conditional put. The first request claims
23
+ * the scope as "pending"; concurrent duplicates see "pending"; once the
24
+ * response is stored via complete(), replays get it back verbatim until the
25
+ * TTL. Records whose expiresAt has passed count as absent (DynamoDB TTL
26
+ * deletion is lazy, so expiry is enforced in the condition, not left to TTL).
27
+ *
28
+ * Every claim carries a random ownerToken, and complete()/abandon() are
29
+ * conditional on still holding it: an original that outlives its pending TTL
30
+ * and loses the scope to a retry can no longer overwrite or delete the
31
+ * retry's claim (both settle calls become silent no-ops instead).
32
+ *
33
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on
34
+ * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
35
+ * shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
36
+ * without key collisions.
37
+ */
38
+ export declare class LambderDdbIdempotency {
39
+ readonly tableName: string;
40
+ readonly keyPrefix: string;
41
+ private readonly client;
42
+ constructor(options: LambderDdbIdempotencyOptions);
43
+ private itemKey;
44
+ /**
45
+ * Claim the scope. "new" means this request now owns it (proven by the
46
+ * returned ownerToken) and must call complete() or abandon(); "pending"
47
+ * means another request owns it right now; "done" carries the stored
48
+ * response to replay.
49
+ */
50
+ begin(scopeKey: string, { pendingTtlSeconds }: {
51
+ pendingTtlSeconds: number;
52
+ }): Promise<LambderIdempotencyBeginResult>;
53
+ /**
54
+ * Store the response for replays, overwriting the pending claim. Requires
55
+ * still holding the claim: returns false (storing nothing) when the
56
+ * ownerToken no longer matches, i.e. the claim expired and a retry took
57
+ * the scope over.
58
+ */
59
+ complete(scopeKey: string, ownerToken: string, { statusCode, contentType, body, ttlSeconds }: {
60
+ statusCode: number;
61
+ contentType: string | null;
62
+ body: string;
63
+ ttlSeconds: number;
64
+ }): Promise<boolean>;
65
+ /**
66
+ * Release the claim without storing a response (crash, uncacheable
67
+ * response), so a retry can execute. Conditional on still holding the
68
+ * claim; a lost claim makes this a silent no-op.
69
+ */
70
+ abandon(scopeKey: string, ownerToken: string): Promise<void>;
71
+ }
72
+ export default LambderDdbIdempotency;
@@ -0,0 +1,132 @@
1
+ import crypto from "crypto";
2
+ import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
3
+ /**
4
+ * DynamoDB-backed idempotency records: one item per (identity, api, key)
5
+ * scope, claimed atomically with a conditional put. The first request claims
6
+ * the scope as "pending"; concurrent duplicates see "pending"; once the
7
+ * response is stored via complete(), replays get it back verbatim until the
8
+ * TTL. Records whose expiresAt has passed count as absent (DynamoDB TTL
9
+ * deletion is lazy, so expiry is enforced in the condition, not left to TTL).
10
+ *
11
+ * Every claim carries a random ownerToken, and complete()/abandon() are
12
+ * conditional on still holding it: an original that outlives its pending TTL
13
+ * and loses the scope to a retry can no longer overwrite or delete the
14
+ * retry's claim (both settle calls become silent no-ops instead).
15
+ *
16
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on
17
+ * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
18
+ * shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
19
+ * without key collisions.
20
+ */
21
+ export class LambderDdbIdempotency {
22
+ tableName;
23
+ keyPrefix;
24
+ client;
25
+ constructor(options) {
26
+ if (!options.tableName.trim())
27
+ throw new Error("tableName is required");
28
+ this.tableName = options.tableName;
29
+ this.keyPrefix = options.keyPrefix ?? "IDEM";
30
+ this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
31
+ }
32
+ itemKey(scopeKey) {
33
+ return { pk: { S: `${this.keyPrefix}#${scopeKey}` }, sk: { S: "idem" } };
34
+ }
35
+ /**
36
+ * Claim the scope. "new" means this request now owns it (proven by the
37
+ * returned ownerToken) and must call complete() or abandon(); "pending"
38
+ * means another request owns it right now; "done" carries the stored
39
+ * response to replay.
40
+ */
41
+ async begin(scopeKey, { pendingTtlSeconds }) {
42
+ const nowSeconds = Math.floor(Date.now() / 1000);
43
+ const ownerToken = crypto.randomBytes(16).toString("hex");
44
+ try {
45
+ await this.client.send(new PutItemCommand({
46
+ TableName: this.tableName,
47
+ Item: {
48
+ ...this.itemKey(scopeKey),
49
+ state: { S: "pending" },
50
+ ownerToken: { S: ownerToken },
51
+ expiresAt: { N: String(nowSeconds + pendingTtlSeconds) },
52
+ },
53
+ ConditionExpression: "attribute_not_exists(pk) OR expiresAt <= :now",
54
+ ExpressionAttributeValues: { ":now": { N: String(nowSeconds) } },
55
+ }));
56
+ return { state: "new", ownerToken };
57
+ }
58
+ catch (error) {
59
+ if (error.name !== "ConditionalCheckFailedException")
60
+ throw error;
61
+ }
62
+ const existing = await this.client.send(new GetItemCommand({
63
+ TableName: this.tableName,
64
+ Key: this.itemKey(scopeKey),
65
+ ConsistentRead: true,
66
+ }));
67
+ const item = existing.Item;
68
+ // Deleted between the put and the read: treat as in-flight, the retry resolves it.
69
+ if (!item)
70
+ return { state: "pending" };
71
+ if (item.state?.S === "done") {
72
+ return {
73
+ state: "done",
74
+ statusCode: Number(item.statusCode?.N ?? 200),
75
+ contentType: item.contentType?.S ?? null,
76
+ body: item.body?.S ?? "",
77
+ };
78
+ }
79
+ return { state: "pending" };
80
+ }
81
+ /**
82
+ * Store the response for replays, overwriting the pending claim. Requires
83
+ * still holding the claim: returns false (storing nothing) when the
84
+ * ownerToken no longer matches, i.e. the claim expired and a retry took
85
+ * the scope over.
86
+ */
87
+ async complete(scopeKey, ownerToken, { statusCode, contentType, body, ttlSeconds }) {
88
+ const nowSeconds = Math.floor(Date.now() / 1000);
89
+ try {
90
+ await this.client.send(new PutItemCommand({
91
+ TableName: this.tableName,
92
+ Item: {
93
+ ...this.itemKey(scopeKey),
94
+ state: { S: "done" },
95
+ ownerToken: { S: ownerToken },
96
+ statusCode: { N: String(statusCode) },
97
+ ...(contentType ? { contentType: { S: contentType } } : {}),
98
+ body: { S: body },
99
+ expiresAt: { N: String(nowSeconds + ttlSeconds) },
100
+ },
101
+ ConditionExpression: "ownerToken = :owner",
102
+ ExpressionAttributeValues: { ":owner": { S: ownerToken } },
103
+ }));
104
+ return true;
105
+ }
106
+ catch (error) {
107
+ if (error.name !== "ConditionalCheckFailedException")
108
+ throw error;
109
+ return false;
110
+ }
111
+ }
112
+ /**
113
+ * Release the claim without storing a response (crash, uncacheable
114
+ * response), so a retry can execute. Conditional on still holding the
115
+ * claim; a lost claim makes this a silent no-op.
116
+ */
117
+ async abandon(scopeKey, ownerToken) {
118
+ try {
119
+ await this.client.send(new DeleteItemCommand({
120
+ TableName: this.tableName,
121
+ Key: this.itemKey(scopeKey),
122
+ ConditionExpression: "ownerToken = :owner",
123
+ ExpressionAttributeValues: { ":owner": { S: ownerToken } },
124
+ }));
125
+ }
126
+ catch (error) {
127
+ if (error.name !== "ConditionalCheckFailedException")
128
+ throw error;
129
+ }
130
+ }
131
+ }
132
+ export default LambderDdbIdempotency;
@@ -13,7 +13,7 @@ export type LambderRateLimitResult = false | LambderRateLimitExceededMap;
13
13
  export interface LambderDdbRateLimiterOptions {
14
14
  tableName: string;
15
15
  region?: string;
16
- /** Partition key prefix, keeps counters separated from other items. */
16
+ /** Partition key prefix, keeps counters separated from other systems in a shared table. Default: "RL". */
17
17
  keyPrefix?: string;
18
18
  /** Multiplier applied to the window length when setting the item TTL. */
19
19
  ttlWindowMultiplier?: number;
@@ -31,6 +31,9 @@ export interface LambderDdbRateLimiterOptions {
31
31
  * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
32
32
  *
33
33
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
34
+ * Items are prefixed `RL#` by default, so the table can be shared with
35
+ * LambderDdbCache (`CACHE#`) and LambderDdbIdempotency (`IDEM#`) without key
36
+ * collisions.
34
37
  */
35
38
  export declare class LambderDdbRateLimiter {
36
39
  readonly tableName: string;
@@ -17,6 +17,9 @@ const WINDOW_CONFIG = [
17
17
  * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
18
18
  *
19
19
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
20
+ * Items are prefixed `RL#` by default, so the table can be shared with
21
+ * LambderDdbCache (`CACHE#`) and LambderDdbIdempotency (`IDEM#`) without key
22
+ * collisions.
20
23
  */
21
24
  export class LambderDdbRateLimiter {
22
25
  tableName;