lambder 14.1.1 → 15.0.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.
- package/CHANGELOG.md +429 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +8 -3
- package/dist/build/checkApiRefusals.js +80 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- package/dist/shared/util/boundKeyField.js +0 -34
|
@@ -2,12 +2,15 @@ import { createDynamoClientLoader, isConditionalCheckFailure } from "./LambderDd
|
|
|
2
2
|
import { getCrypto } from "../shared/util/LambderNodeModules.js";
|
|
3
3
|
import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
|
|
4
4
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
5
|
-
import {
|
|
5
|
+
import { LambderCompressionError } from "../shared/wire/LambderCompressionCodec.js";
|
|
6
6
|
import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
|
|
7
7
|
import { LRUCache } from "lru-cache";
|
|
8
8
|
import { cacheMemoryKeyOf, decodeCacheSortKey, encodeCacheSortKey, normalizeCacheKey, normalizeCachePartition, } from "./LambderCacheKeys.js";
|
|
9
9
|
import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
|
|
10
10
|
import { LambderCacheFiller } from "./LambderCacheFiller.js";
|
|
11
|
+
import { restoreStoredText, storedTextOf } from "./LambderStoredText.js";
|
|
12
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
13
|
+
import { delegateToTwin, registerSwappableInstance } from "../shared/util/LambderSwappableInstances.js";
|
|
11
14
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
12
15
|
const MAX_SAFE_CHUNK_BYTES = 380 * 1024;
|
|
13
16
|
const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
|
|
@@ -155,6 +158,10 @@ class LocalWriteLedger {
|
|
|
155
158
|
return Math.floor(this.now() / 1000) + RECENT_WRITE_SECONDS;
|
|
156
159
|
}
|
|
157
160
|
}
|
|
161
|
+
/** Every LambderCache member, which the swap door hands to a memory twin. */
|
|
162
|
+
const CACHE_MEMBERS = {
|
|
163
|
+
get: true, has: true, set: true, delete: true, deletePartition: true, listSortKeys: true, getOrSet: true,
|
|
164
|
+
};
|
|
158
165
|
/**
|
|
159
166
|
* Persistent JSON cache backed by DynamoDB.
|
|
160
167
|
*
|
|
@@ -235,6 +242,18 @@ export class LambderDdbCache {
|
|
|
235
242
|
this.localWrites = new LocalWriteLedger(this.now);
|
|
236
243
|
this.ready = createDynamoClientLoader({ user: "LambderDdbCache", region: options.region, client: options.client });
|
|
237
244
|
this.filler = new LambderCacheFiller(`DynamoDB cache failed open in ${this.namespace}`);
|
|
245
|
+
registerSwappableInstance(this);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Puts a memory twin under this cache in place, for `lambder/testing`:
|
|
249
|
+
* every LambderCache member answers from the twin from then on. The twin
|
|
250
|
+
* is built with this cache's own default TTL, size limit and clock, so a
|
|
251
|
+
* test meets the rules this cache was configured with. Keyed by a symbol
|
|
252
|
+
* no entry point exports; see registerSwappableInstance.
|
|
253
|
+
*/
|
|
254
|
+
[LAMBDER_BACKEND_SWAP](twins) {
|
|
255
|
+
const twin = twins.cache({ defaultTtlSeconds: this.defaultTtlSeconds, maxValueBytes: this.maxValueBytes, now: this.now });
|
|
256
|
+
delegateToTwin(this, twin, CACHE_MEMBERS);
|
|
238
257
|
}
|
|
239
258
|
async get(key) {
|
|
240
259
|
return await this.getByAddress(normalizeCacheKey(key));
|
|
@@ -358,9 +377,7 @@ export class LambderDdbCache {
|
|
|
358
377
|
async setByAddress(address, value, ttlSeconds, leaseOwner) {
|
|
359
378
|
const { json, utf8 } = serializeCacheValue(value, this.maxValueBytes);
|
|
360
379
|
const input = Buffer.from(utf8.buffer, utf8.byteOffset, utf8.byteLength);
|
|
361
|
-
const
|
|
362
|
-
const encoding = brotli ? "br" : "identity";
|
|
363
|
-
const stored = brotli ? await compressText(input, "br", brotli.quality) : input;
|
|
380
|
+
const { encoding, stored } = await storedTextOf(input, this.compression);
|
|
364
381
|
if (stored.length > this.maxValueBytes) {
|
|
365
382
|
throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
|
|
366
383
|
}
|
|
@@ -881,9 +898,11 @@ export class LambderDdbCache {
|
|
|
881
898
|
}
|
|
882
899
|
}
|
|
883
900
|
}
|
|
884
|
-
/** The JSON text of a stored payload. */
|
|
901
|
+
/** The JSON text of a stored payload, restored under maxValueBytes, the most this cache writes. */
|
|
885
902
|
async decode(stored, encoding, uncompressedBytes) {
|
|
886
|
-
|
|
903
|
+
if (encoding === "identity")
|
|
904
|
+
return stored.toString("utf8");
|
|
905
|
+
return await restoreStoredText(stored, uncompressedBytes, { user: "LambderDdbCache", maxTextBytes: this.maxValueBytes });
|
|
887
906
|
}
|
|
888
907
|
remember(key, stored, encoding, uncompressedBytes, expiresAt) {
|
|
889
908
|
if (!this.memory)
|
|
@@ -75,8 +75,9 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
|
|
|
75
75
|
/**
|
|
76
76
|
* A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
|
|
77
77
|
* `bodyBytes`). The stored length is the decompression budget, so it is
|
|
78
|
-
* checked
|
|
79
|
-
* store ever writes is unusable, not an
|
|
78
|
+
* checked against the ceiling the way the headers are checked: a record
|
|
79
|
+
* declaring more than this store ever writes is unusable, not an
|
|
80
|
+
* invitation to allocate it.
|
|
80
81
|
*/
|
|
81
82
|
private static readItemBody;
|
|
82
83
|
/** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
|
|
@@ -113,8 +114,9 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
|
|
|
113
114
|
* compression off, stay plain. Returns:
|
|
114
115
|
*
|
|
115
116
|
* - "stored": the record is in place and will replay.
|
|
116
|
-
* - "too-large":
|
|
117
|
-
* nothing was written and the
|
|
117
|
+
* - "too-large": the body is past the store's ceiling, or even
|
|
118
|
+
* compressed it exceeds the item budget; nothing was written and the
|
|
119
|
+
* caller should release the claim.
|
|
118
120
|
* - "lost": the ownerToken no longer matches, i.e. the claim expired and
|
|
119
121
|
* a retry took the scope over; nothing was written.
|
|
120
122
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
|
|
1
|
+
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, storedNumber, } from "./LambderDdbSdk.js";
|
|
2
|
+
import { restoreStoredText, storedTextOf } from "./LambderStoredText.js";
|
|
2
3
|
import { getCrypto } from "../shared/util/LambderNodeModules.js";
|
|
3
|
-
import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
|
|
4
4
|
import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
|
|
5
5
|
/** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
|
|
6
6
|
const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
|
|
@@ -11,15 +11,13 @@ const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
|
|
|
11
11
|
*/
|
|
12
12
|
const MAX_STORED_BODY_BYTES = 350_000;
|
|
13
13
|
/**
|
|
14
|
-
* Ceiling on a stored body
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* until the function dies. The cache bounds the same number against its
|
|
20
|
-
* maxValueBytes.
|
|
14
|
+
* Ceiling on a stored body, in UTF-8 bytes: the most the store writes and the
|
|
15
|
+
* most it restores (see LambderStoredText). A response that reaches a client
|
|
16
|
+
* is a few megabytes at most, so a body past it answers "too-large", and a
|
|
17
|
+
* record declaring more is not this store's. The cache bounds the same
|
|
18
|
+
* number against its maxValueBytes.
|
|
21
19
|
*/
|
|
22
|
-
const
|
|
20
|
+
const BODY_CEILING = { user: "LambderDdbIdempotencyStore", maxTextBytes: 32 * 1024 * 1024 };
|
|
23
21
|
/**
|
|
24
22
|
* What an item that keeps no fingerprint reports: one no request matches,
|
|
25
23
|
* since the engine's fingerprints are never empty. Such an item was not
|
|
@@ -29,16 +27,6 @@ const MAX_REPLAY_BODY_BYTES = 32 * 1024 * 1024;
|
|
|
29
27
|
* free and running the request over it.
|
|
30
28
|
*/
|
|
31
29
|
const UNKNOWN_REQUEST_FINGERPRINT = "";
|
|
32
|
-
/**
|
|
33
|
-
* A number attribute as stored, or the fallback when it is missing or not a
|
|
34
|
-
* number. `Number(undefined)` and `Number("nope")` are both NaN, which every
|
|
35
|
-
* later comparison answers false to: a NaN expiry reads as "not expired" and
|
|
36
|
-
* a NaN status code reaches the client as one.
|
|
37
|
-
*/
|
|
38
|
-
const storedNumber = (raw, fallback) => {
|
|
39
|
-
const value = Number(raw);
|
|
40
|
-
return Number.isFinite(value) ? value : fallback;
|
|
41
|
-
};
|
|
42
30
|
/** 16 random bytes, hex, through the optional-crypto seam so a bundler's browser stub cannot break the import. */
|
|
43
31
|
const newOwnerToken = async () => {
|
|
44
32
|
const crypto = await getCrypto();
|
|
@@ -138,18 +126,15 @@ export class LambderDdbIdempotencyStore {
|
|
|
138
126
|
/**
|
|
139
127
|
* A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
|
|
140
128
|
* `bodyBytes`). The stored length is the decompression budget, so it is
|
|
141
|
-
* checked
|
|
142
|
-
* store ever writes is unusable, not an
|
|
129
|
+
* checked against the ceiling the way the headers are checked: a record
|
|
130
|
+
* declaring more than this store ever writes is unusable, not an
|
|
131
|
+
* invitation to allocate it.
|
|
143
132
|
*/
|
|
144
133
|
static async readItemBody(item) {
|
|
145
134
|
const compressed = item.bodyBr?.B;
|
|
146
135
|
if (!compressed)
|
|
147
136
|
return item.body?.S ?? "";
|
|
148
|
-
|
|
149
|
-
if (declaredBytes > MAX_REPLAY_BODY_BYTES) {
|
|
150
|
-
throw new Error(`LambderDdbIdempotencyStore: the stored body declares ${declaredBytes} bytes, over the ${MAX_REPLAY_BODY_BYTES}-byte replay limit, so the record is unusable.`);
|
|
151
|
-
}
|
|
152
|
-
return await restoreText(compressed, "br", { declaredBytes });
|
|
137
|
+
return await restoreStoredText(compressed, storedNumber(item.bodyBytes?.N, 0), BODY_CEILING);
|
|
153
138
|
}
|
|
154
139
|
/** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
|
|
155
140
|
static async answerOf(item) {
|
|
@@ -252,37 +237,32 @@ export class LambderDdbIdempotencyStore {
|
|
|
252
237
|
* compression off, stay plain. Returns:
|
|
253
238
|
*
|
|
254
239
|
* - "stored": the record is in place and will replay.
|
|
255
|
-
* - "too-large":
|
|
256
|
-
* nothing was written and the
|
|
240
|
+
* - "too-large": the body is past the store's ceiling, or even
|
|
241
|
+
* compressed it exceeds the item budget; nothing was written and the
|
|
242
|
+
* caller should release the claim.
|
|
257
243
|
* - "lost": the ownerToken no longer matches, i.e. the claim expired and
|
|
258
244
|
* a retry took the scope over; nothing was written.
|
|
259
245
|
*/
|
|
260
246
|
async complete(scopeKey, ownerToken, { statusCode, headers, body, fingerprint, ttlSeconds }) {
|
|
261
247
|
const nowSeconds = this.nowSeconds();
|
|
262
248
|
const rawBody = Buffer.from(body, "utf8");
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// comes back as a ValidationException, which is not a
|
|
281
|
-
// ConditionalCheckFailedException and so escapes as a store error.
|
|
282
|
-
if (rawBody.byteLength > MAX_STORED_BODY_BYTES)
|
|
283
|
-
return "too-large";
|
|
284
|
-
bodyAttributes = { body: { S: body } };
|
|
285
|
-
}
|
|
249
|
+
// Past the ceiling, the record would be one the store refuses to
|
|
250
|
+
// read back, however small its compressed bytes.
|
|
251
|
+
if (rawBody.byteLength > BODY_CEILING.maxTextBytes)
|
|
252
|
+
return "too-large";
|
|
253
|
+
const text = await storedTextOf(rawBody, this.compression);
|
|
254
|
+
// The budget is on what actually gets stored, plain or compressed.
|
|
255
|
+
// Without it an oversized body reaches DynamoDB and comes back as a
|
|
256
|
+
// ValidationException, which is not a ConditionalCheckFailedException
|
|
257
|
+
// and so escapes as a store error.
|
|
258
|
+
if (text.stored.byteLength > MAX_STORED_BODY_BYTES)
|
|
259
|
+
return "too-large";
|
|
260
|
+
// bodyBytes bounds and verifies decompression on read. The plain path
|
|
261
|
+
// stores the body as a string, an empty one included, which reads
|
|
262
|
+
// back as one.
|
|
263
|
+
const bodyAttributes = text.encoding === "br"
|
|
264
|
+
? { bodyBr: { B: text.stored }, bodyBytes: { N: String(text.textBytes) } }
|
|
265
|
+
: { body: { S: body } };
|
|
286
266
|
try {
|
|
287
267
|
const { client, sdk } = await this.ready();
|
|
288
268
|
await client.send(new sdk.PutItemCommand({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import type { LambderOneShotIssueOutcome, LambderOneShotSecretDraft, LambderOneShotSecretRecord, LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
|
|
3
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
3
4
|
export interface LambderDdbOneShotSecretStoreOptions {
|
|
4
5
|
tableName: string;
|
|
5
6
|
/** Region the client is created for on first use; the SDK's default chain otherwise. */
|
|
@@ -44,6 +45,16 @@ export declare class LambderDdbOneShotSecretStore implements LambderOneShotSecre
|
|
|
44
45
|
readonly keyPrefix: string;
|
|
45
46
|
private readonly ready;
|
|
46
47
|
constructor(options: LambderDdbOneShotSecretStoreOptions);
|
|
48
|
+
/**
|
|
49
|
+
* Puts a memory twin under this store in place, for `lambder/testing`:
|
|
50
|
+
* every LambderOneShotSecretStore member answers from the twin from then
|
|
51
|
+
* on, so the LambderOneShotSecrets built over this store issues and
|
|
52
|
+
* verifies in memory. Keyed by a symbol no entry point exports; see
|
|
53
|
+
* registerSwappableInstance.
|
|
54
|
+
*/
|
|
55
|
+
[LAMBDER_BACKEND_SWAP](twins: {
|
|
56
|
+
oneShotSecretStore(): LambderOneShotSecretStore;
|
|
57
|
+
}): void;
|
|
47
58
|
private scopeKey;
|
|
48
59
|
private digestKey;
|
|
49
60
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
|
|
1
|
+
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, storedNumber, } from "./LambderDdbSdk.js";
|
|
2
2
|
import { randomSecret } from "../shared/util/LambderSignedClaims.js";
|
|
3
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
4
|
+
import { delegateToTwin, registerSwappableInstance } from "../shared/util/LambderSwappableInstances.js";
|
|
3
5
|
/**
|
|
4
6
|
* Why each item of a cancelled transaction was refused, in the order the
|
|
5
7
|
* items were sent, or null when the error is not a cancelled transaction.
|
|
@@ -46,10 +48,9 @@ const sendRetryingConflicts = async (write) => {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
};
|
|
49
|
-
/**
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
return Number.isFinite(value) ? value : fallback;
|
|
51
|
+
/** Every LambderOneShotSecretStore member, which the swap door hands to a memory twin. */
|
|
52
|
+
const ONE_SHOT_SECRET_STORE_MEMBERS = {
|
|
53
|
+
issue: true, findByScope: true, findByDigest: true, attempt: true, consume: true, retire: true,
|
|
53
54
|
};
|
|
54
55
|
/**
|
|
55
56
|
* One-shot secrets in DynamoDB, under the store's prefix, so the table can be
|
|
@@ -92,6 +93,17 @@ export class LambderDdbOneShotSecretStore {
|
|
|
92
93
|
this.tableName = options.tableName;
|
|
93
94
|
this.keyPrefix = options.keyPrefix ?? "OTS";
|
|
94
95
|
this.ready = createDynamoClientLoader({ user: "LambderDdbOneShotSecretStore", region: options.region, client: options.client });
|
|
96
|
+
registerSwappableInstance(this);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Puts a memory twin under this store in place, for `lambder/testing`:
|
|
100
|
+
* every LambderOneShotSecretStore member answers from the twin from then
|
|
101
|
+
* on, so the LambderOneShotSecrets built over this store issues and
|
|
102
|
+
* verifies in memory. Keyed by a symbol no entry point exports; see
|
|
103
|
+
* registerSwappableInstance.
|
|
104
|
+
*/
|
|
105
|
+
[LAMBDER_BACKEND_SWAP](twins) {
|
|
106
|
+
delegateToTwin(this, twins.oneShotSecretStore(), ONE_SHOT_SECRET_STORE_MEMBERS);
|
|
95
107
|
}
|
|
96
108
|
scopeKey(scope) {
|
|
97
109
|
return {
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The DynamoDB SDK, loaded on first use.
|
|
3
3
|
*
|
|
4
|
-
* `@aws-sdk/client-dynamodb`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the session manager and the stores import their types only and take the
|
|
4
|
+
* `@aws-sdk/client-dynamodb` is an optional peer dependency: an app that uses
|
|
5
|
+
* no DynamoDB store should neither install it nor pay for loading it, and a
|
|
6
|
+
* module-level import would cost every cold start and make every bundled
|
|
7
|
+
* Lambder app reference it. So the stores import its types only and take the
|
|
9
8
|
* classes from here the first time they touch the table, as
|
|
10
|
-
* LambderS3FileSource and LambderInvokeCaller do. One loader
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* LambderS3FileSource and LambderInvokeCaller do. One loader, memoized for
|
|
10
|
+
* the container's life; a missing package fails that first call with the
|
|
11
|
+
* install hint rather than failing the import of lambder itself.
|
|
12
|
+
*
|
|
13
|
+
* Every store speaks the item-level API, attribute values and all, so they
|
|
14
|
+
* take one client type and need one package. The session store, the one
|
|
15
|
+
* whose items hold an app's JSON, converts it with marshallJsonValue and
|
|
16
|
+
* unmarshallJsonValue below, so no store needs `@aws-sdk/lib-dynamodb`.
|
|
13
17
|
*
|
|
14
18
|
* The facts about DynamoDB itself that every store must agree on live here
|
|
15
|
-
* too: a conditional write's refusal is an answer rather than a failure,
|
|
16
|
-
*
|
|
19
|
+
* too: a conditional write's refusal is an answer rather than a failure, a
|
|
20
|
+
* partition key and an item each have a limit that caller data can pass, and
|
|
21
|
+
* a number attribute read back may be missing or not a number.
|
|
17
22
|
*/
|
|
18
|
-
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
19
|
-
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
23
|
+
import type { AttributeValue, DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
20
24
|
type LambderDynamoClientSdk = typeof import("@aws-sdk/client-dynamodb");
|
|
21
|
-
type LambderDynamoDocumentSdk = typeof import("@aws-sdk/lib-dynamodb");
|
|
22
25
|
/** What every DynamoDB-backed store needs before it can touch its table: the SDK, and a client made with it. */
|
|
23
26
|
export type LambderDynamoClientReady = {
|
|
24
27
|
client: DynamoDBClient;
|
|
@@ -42,22 +45,6 @@ export declare const createDynamoClientLoader: (options: {
|
|
|
42
45
|
region?: string;
|
|
43
46
|
client?: DynamoDBClient;
|
|
44
47
|
}) => (() => Promise<LambderDynamoClientReady>);
|
|
45
|
-
/** What the session store needs before its first table call: the document SDK, and a document client made with it. */
|
|
46
|
-
export type LambderDynamoDocumentClientReady = {
|
|
47
|
-
client: DynamoDBDocumentClient;
|
|
48
|
-
sdk: LambderDynamoDocumentSdk;
|
|
49
|
-
};
|
|
50
|
-
/**
|
|
51
|
-
* The document-client twin of createDynamoClientLoader, for the one store
|
|
52
|
-
* that speaks the document API. A supplied client is taken as is, and then
|
|
53
|
-
* only `@aws-sdk/lib-dynamodb` is loaded: the item-level package is needed
|
|
54
|
-
* only to construct a client of our own.
|
|
55
|
-
*/
|
|
56
|
-
export declare const createDynamoDocumentClientLoader: (options: {
|
|
57
|
-
user: string;
|
|
58
|
-
region?: string;
|
|
59
|
-
client?: DynamoDBDocumentClient;
|
|
60
|
-
}) => (() => Promise<LambderDynamoDocumentClientReady>);
|
|
61
48
|
/**
|
|
62
49
|
* Whether DynamoDB refused a write because its condition did not hold, which
|
|
63
50
|
* is how every conditional write here reports the thing it was testing for: a
|
|
@@ -106,4 +93,47 @@ export declare const assertPartitionKeyFits: (options: {
|
|
|
106
93
|
partitionKey: string;
|
|
107
94
|
remedy: string;
|
|
108
95
|
}) => string;
|
|
96
|
+
/** DynamoDB's own limit on one item, its attribute names and values together: 400 KB. */
|
|
97
|
+
export declare const MAX_ITEM_BYTES: number;
|
|
98
|
+
/**
|
|
99
|
+
* The size DynamoDB counts for one attribute value against its item limit: a
|
|
100
|
+
* string's UTF-8 bytes, a binary's bytes, one byte for a boolean or null, a
|
|
101
|
+
* byte per two significant digits of a number and one more, and for a map
|
|
102
|
+
* or a list three bytes plus, per element, one byte, its name (a map's) and
|
|
103
|
+
* its value. These are the rules DynamoDB states for its item limit, so a
|
|
104
|
+
* store can refuse an item by its size before the table answers it with a
|
|
105
|
+
* ValidationException, which is not a ConditionalCheckFailedException and
|
|
106
|
+
* so escapes as a store error that names nothing the app can act on. The
|
|
107
|
+
* set types are sized nowhere because no store writes one.
|
|
108
|
+
*/
|
|
109
|
+
export declare const attributeValueBytes: (value: AttributeValue) => number;
|
|
110
|
+
/**
|
|
111
|
+
* A JSON value as the attribute DynamoDB keeps it: a string as `S`, a number
|
|
112
|
+
* as `N` in its shortest round-trip text, a boolean as `BOOL`, null as
|
|
113
|
+
* `NULL`, an array as `L` and an object as `M`. This is what
|
|
114
|
+
* `@aws-sdk/lib-dynamodb`'s document client writes for the same value, so an
|
|
115
|
+
* item written either way reads back through unmarshallJsonValue.
|
|
116
|
+
*
|
|
117
|
+
* JSON only, as the name says: anything JSON.parse cannot hand back (an
|
|
118
|
+
* undefined, a non-finite number, a function, a Set, a class instance) is
|
|
119
|
+
* refused rather than guessed at. Map entries are built with
|
|
120
|
+
* Object.fromEntries so that a `__proto__` key, which JSON.parse keeps as an
|
|
121
|
+
* own key, stays an entry rather than setting the prototype of the map.
|
|
122
|
+
*/
|
|
123
|
+
export declare const marshallJsonValue: (value: unknown) => AttributeValue;
|
|
124
|
+
/**
|
|
125
|
+
* The JSON value an attribute holds: the inverse of marshallJsonValue, and
|
|
126
|
+
* the reading of what the document client wrote for one. A number reads as
|
|
127
|
+
* the JS number its text names, as JSON.parse would read the same text. An
|
|
128
|
+
* attribute JSON has no form for (a binary, a set) throws: it was not
|
|
129
|
+
* written from JSON, so it is not this store's.
|
|
130
|
+
*/
|
|
131
|
+
export declare const unmarshallJsonValue: (attribute: AttributeValue) => unknown;
|
|
132
|
+
/**
|
|
133
|
+
* A number attribute as stored, or the fallback when it is missing or not a
|
|
134
|
+
* number. `Number(undefined)` and `Number("nope")` are both NaN, which every
|
|
135
|
+
* later comparison answers false to: a NaN expiry reads as "not expired" and
|
|
136
|
+
* a NaN status code reaches the client as one.
|
|
137
|
+
*/
|
|
138
|
+
export declare const storedNumber: (raw: string | undefined, fallback: number) => number;
|
|
109
139
|
export {};
|
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The DynamoDB SDK, loaded on first use.
|
|
3
3
|
*
|
|
4
|
-
* `@aws-sdk/client-dynamodb`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the session manager and the stores import their types only and take the
|
|
4
|
+
* `@aws-sdk/client-dynamodb` is an optional peer dependency: an app that uses
|
|
5
|
+
* no DynamoDB store should neither install it nor pay for loading it, and a
|
|
6
|
+
* module-level import would cost every cold start and make every bundled
|
|
7
|
+
* Lambder app reference it. So the stores import its types only and take the
|
|
9
8
|
* classes from here the first time they touch the table, as
|
|
10
|
-
* LambderS3FileSource and LambderInvokeCaller do. One loader
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* LambderS3FileSource and LambderInvokeCaller do. One loader, memoized for
|
|
10
|
+
* the container's life; a missing package fails that first call with the
|
|
11
|
+
* install hint rather than failing the import of lambder itself.
|
|
12
|
+
*
|
|
13
|
+
* Every store speaks the item-level API, attribute values and all, so they
|
|
14
|
+
* take one client type and need one package. The session store, the one
|
|
15
|
+
* whose items hold an app's JSON, converts it with marshallJsonValue and
|
|
16
|
+
* unmarshallJsonValue below, so no store needs `@aws-sdk/lib-dynamodb`.
|
|
13
17
|
*
|
|
14
18
|
* The facts about DynamoDB itself that every store must agree on live here
|
|
15
|
-
* too: a conditional write's refusal is an answer rather than a failure,
|
|
16
|
-
*
|
|
19
|
+
* too: a conditional write's refusal is an answer rather than a failure, a
|
|
20
|
+
* partition key and an item each have a limit that caller data can pass, and
|
|
21
|
+
* a number attribute read back may be missing or not a number.
|
|
17
22
|
*/
|
|
18
23
|
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
19
24
|
let clientSdk;
|
|
20
|
-
|
|
21
|
-
/** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
|
|
25
|
+
/** `@aws-sdk/client-dynamodb`, for the item-level API every store speaks. */
|
|
22
26
|
const loadDynamoClientSdk = (user) => {
|
|
23
27
|
clientSdk ??= withInstallHint(import("@aws-sdk/client-dynamodb"), "@aws-sdk/client-dynamodb", user, () => { clientSdk = undefined; });
|
|
24
28
|
return clientSdk;
|
|
25
29
|
};
|
|
26
|
-
/** `@aws-sdk/lib-dynamodb`, the document client the session manager reads and writes through. */
|
|
27
|
-
const loadDynamoDocumentSdk = (user) => {
|
|
28
|
-
documentSdk ??= withInstallHint(import("@aws-sdk/lib-dynamodb"), "@aws-sdk/lib-dynamodb", user, () => { documentSdk = undefined; });
|
|
29
|
-
return documentSdk;
|
|
30
|
-
};
|
|
31
30
|
/**
|
|
32
31
|
* The default clients, one per region ("" for the SDK's default chain),
|
|
33
32
|
* shared by every store not given a client of its own. A client is a
|
|
@@ -37,7 +36,6 @@ const loadDynamoDocumentSdk = (user) => {
|
|
|
37
36
|
* first request.
|
|
38
37
|
*/
|
|
39
38
|
const defaultClients = new Map();
|
|
40
|
-
const defaultDocumentClients = new Map();
|
|
41
39
|
const defaultClientFor = (sdk, region) => {
|
|
42
40
|
let client = defaultClients.get(region ?? "");
|
|
43
41
|
if (!client) {
|
|
@@ -68,32 +66,6 @@ export const createDynamoClientLoader = (options) => {
|
|
|
68
66
|
return readyPromise;
|
|
69
67
|
};
|
|
70
68
|
};
|
|
71
|
-
/**
|
|
72
|
-
* The document-client twin of createDynamoClientLoader, for the one store
|
|
73
|
-
* that speaks the document API. A supplied client is taken as is, and then
|
|
74
|
-
* only `@aws-sdk/lib-dynamodb` is loaded: the item-level package is needed
|
|
75
|
-
* only to construct a client of our own.
|
|
76
|
-
*/
|
|
77
|
-
export const createDynamoDocumentClientLoader = (options) => {
|
|
78
|
-
let readyPromise;
|
|
79
|
-
const build = async () => {
|
|
80
|
-
if (options.client)
|
|
81
|
-
return { sdk: await loadDynamoDocumentSdk(options.user), client: options.client };
|
|
82
|
-
const [clientSdk, sdk] = await Promise.all([loadDynamoClientSdk(options.user), loadDynamoDocumentSdk(options.user)]);
|
|
83
|
-
// Over the same shared client the item-level stores use, so the
|
|
84
|
-
// session store's calls go through that one connection pool too.
|
|
85
|
-
let client = defaultDocumentClients.get(options.region ?? "");
|
|
86
|
-
if (!client) {
|
|
87
|
-
client = sdk.DynamoDBDocumentClient.from(defaultClientFor(clientSdk, options.region));
|
|
88
|
-
defaultDocumentClients.set(options.region ?? "", client);
|
|
89
|
-
}
|
|
90
|
-
return { sdk, client };
|
|
91
|
-
};
|
|
92
|
-
return () => {
|
|
93
|
-
readyPromise ??= build().catch((error) => { readyPromise = undefined; throw error; });
|
|
94
|
-
return readyPromise;
|
|
95
|
-
};
|
|
96
|
-
};
|
|
97
69
|
/**
|
|
98
70
|
* Whether DynamoDB refused a write because its condition did not hold, which
|
|
99
71
|
* is how every conditional write here reports the thing it was testing for: a
|
|
@@ -152,3 +124,103 @@ export const assertPartitionKeyFits = (options) => {
|
|
|
152
124
|
}
|
|
153
125
|
return options.partitionKey;
|
|
154
126
|
};
|
|
127
|
+
/** DynamoDB's own limit on one item, its attribute names and values together: 400 KB. */
|
|
128
|
+
export const MAX_ITEM_BYTES = 400 * 1024;
|
|
129
|
+
/**
|
|
130
|
+
* The significant digits of a number attribute's text: what DynamoDB stores
|
|
131
|
+
* of it. Leading and trailing zeroes are not stored, nor are the sign, the
|
|
132
|
+
* point and an exponent.
|
|
133
|
+
*/
|
|
134
|
+
const significantDigits = (numberText) => Math.max(numberText.replace(/^[-+]/, "").split(/e/i)[0].replace(".", "").replace(/^0+/, "").replace(/0+$/, "").length, 1);
|
|
135
|
+
/**
|
|
136
|
+
* The size DynamoDB counts for one attribute value against its item limit: a
|
|
137
|
+
* string's UTF-8 bytes, a binary's bytes, one byte for a boolean or null, a
|
|
138
|
+
* byte per two significant digits of a number and one more, and for a map
|
|
139
|
+
* or a list three bytes plus, per element, one byte, its name (a map's) and
|
|
140
|
+
* its value. These are the rules DynamoDB states for its item limit, so a
|
|
141
|
+
* store can refuse an item by its size before the table answers it with a
|
|
142
|
+
* ValidationException, which is not a ConditionalCheckFailedException and
|
|
143
|
+
* so escapes as a store error that names nothing the app can act on. The
|
|
144
|
+
* set types are sized nowhere because no store writes one.
|
|
145
|
+
*/
|
|
146
|
+
export const attributeValueBytes = (value) => {
|
|
147
|
+
if (value.S !== undefined)
|
|
148
|
+
return Buffer.byteLength(value.S, "utf8");
|
|
149
|
+
if (value.N !== undefined)
|
|
150
|
+
return Math.ceil(significantDigits(value.N) / 2) + 1;
|
|
151
|
+
if (value.B !== undefined)
|
|
152
|
+
return value.B.byteLength;
|
|
153
|
+
if (value.BOOL !== undefined || value.NULL !== undefined)
|
|
154
|
+
return 1;
|
|
155
|
+
if (value.L !== undefined)
|
|
156
|
+
return value.L.reduce((total, element) => total + 1 + attributeValueBytes(element), 3);
|
|
157
|
+
if (value.M !== undefined) {
|
|
158
|
+
return Object.entries(value.M).reduce((total, [name, element]) => total + 1 + Buffer.byteLength(name, "utf8") + attributeValueBytes(element), 3);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`attributeValueBytes cannot size an attribute of type ${Object.keys(value).join(", ") || "none"}`);
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* A JSON value as the attribute DynamoDB keeps it: a string as `S`, a number
|
|
164
|
+
* as `N` in its shortest round-trip text, a boolean as `BOOL`, null as
|
|
165
|
+
* `NULL`, an array as `L` and an object as `M`. This is what
|
|
166
|
+
* `@aws-sdk/lib-dynamodb`'s document client writes for the same value, so an
|
|
167
|
+
* item written either way reads back through unmarshallJsonValue.
|
|
168
|
+
*
|
|
169
|
+
* JSON only, as the name says: anything JSON.parse cannot hand back (an
|
|
170
|
+
* undefined, a non-finite number, a function, a Set, a class instance) is
|
|
171
|
+
* refused rather than guessed at. Map entries are built with
|
|
172
|
+
* Object.fromEntries so that a `__proto__` key, which JSON.parse keeps as an
|
|
173
|
+
* own key, stays an entry rather than setting the prototype of the map.
|
|
174
|
+
*/
|
|
175
|
+
export const marshallJsonValue = (value) => {
|
|
176
|
+
if (value === null)
|
|
177
|
+
return { NULL: true };
|
|
178
|
+
if (typeof value === "string")
|
|
179
|
+
return { S: value };
|
|
180
|
+
if (typeof value === "boolean")
|
|
181
|
+
return { BOOL: value };
|
|
182
|
+
if (typeof value === "number") {
|
|
183
|
+
if (!Number.isFinite(value))
|
|
184
|
+
throw new Error(`marshallJsonValue cannot store the number ${value}: JSON has no such value.`);
|
|
185
|
+
return { N: String(value) };
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(value))
|
|
188
|
+
return { L: value.map(marshallJsonValue) };
|
|
189
|
+
if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
190
|
+
return { M: Object.fromEntries(Object.entries(value).map(([name, element]) => [name, marshallJsonValue(element)])) };
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`marshallJsonValue cannot store a value of type ${value === undefined ? "undefined" : typeof value}: it is not JSON.`);
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* The JSON value an attribute holds: the inverse of marshallJsonValue, and
|
|
196
|
+
* the reading of what the document client wrote for one. A number reads as
|
|
197
|
+
* the JS number its text names, as JSON.parse would read the same text. An
|
|
198
|
+
* attribute JSON has no form for (a binary, a set) throws: it was not
|
|
199
|
+
* written from JSON, so it is not this store's.
|
|
200
|
+
*/
|
|
201
|
+
export const unmarshallJsonValue = (attribute) => {
|
|
202
|
+
if (attribute.S !== undefined)
|
|
203
|
+
return attribute.S;
|
|
204
|
+
if (attribute.N !== undefined)
|
|
205
|
+
return Number(attribute.N);
|
|
206
|
+
if (attribute.BOOL !== undefined)
|
|
207
|
+
return attribute.BOOL;
|
|
208
|
+
if (attribute.NULL !== undefined)
|
|
209
|
+
return null;
|
|
210
|
+
if (attribute.L !== undefined)
|
|
211
|
+
return attribute.L.map(unmarshallJsonValue);
|
|
212
|
+
if (attribute.M !== undefined) {
|
|
213
|
+
return Object.fromEntries(Object.entries(attribute.M).map(([name, element]) => [name, unmarshallJsonValue(element)]));
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`unmarshallJsonValue cannot read an attribute of type ${Object.keys(attribute).join(", ") || "none"} as JSON.`);
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* A number attribute as stored, or the fallback when it is missing or not a
|
|
219
|
+
* number. `Number(undefined)` and `Number("nope")` are both NaN, which every
|
|
220
|
+
* later comparison answers false to: a NaN expiry reads as "not expired" and
|
|
221
|
+
* a NaN status code reaches the client as one.
|
|
222
|
+
*/
|
|
223
|
+
export const storedNumber = (raw, fallback) => {
|
|
224
|
+
const value = Number(raw);
|
|
225
|
+
return Number.isFinite(value) ? value : fallback;
|
|
226
|
+
};
|