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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import { type LambderCompressionOption } from "../shared/wire/LambderCompressionOption.js";
|
|
3
3
|
import type { LambderSessionChanges, LambderSessionRecord, LambderSessionStore, LambderSessionUpdateResult } from "../shared/contracts/LambderSessionStore.js";
|
|
4
4
|
export type LambderDdbSessionStoreOptions = {
|
|
@@ -16,8 +16,8 @@ export type LambderDdbSessionStoreOptions = {
|
|
|
16
16
|
* it can be switched on or off on a live table.
|
|
17
17
|
*/
|
|
18
18
|
compression?: LambderCompressionOption;
|
|
19
|
-
/** A ready
|
|
20
|
-
client?:
|
|
19
|
+
/** A ready client, e.g. one shared with the rest of the app; the stores' shared one for the region otherwise. */
|
|
20
|
+
client?: DynamoDBClient;
|
|
21
21
|
};
|
|
22
22
|
/**
|
|
23
23
|
* Sessions at rest in DynamoDB: one item per session under the two hashes,
|
|
@@ -46,19 +46,34 @@ export declare class LambderDdbSessionStore<SessionData = unknown> implements La
|
|
|
46
46
|
private keyOf;
|
|
47
47
|
/**
|
|
48
48
|
* session.data as the attributes that hold it: `dataBr` and `dataBytes`
|
|
49
|
-
* when compressed, a plain `data` otherwise. Either way it goes
|
|
50
|
-
* its JSON first, so a plain record holds exactly what a
|
|
51
|
-
* restores to: an `undefined` inside the data is dropped
|
|
52
|
-
*
|
|
53
|
-
* write (a login answering 500) only when
|
|
49
|
+
* when compressed, a plain `data` attribute otherwise. Either way it goes
|
|
50
|
+
* through its JSON first, so a plain record holds exactly what a
|
|
51
|
+
* compressed one restores to: an `undefined` inside the data is dropped,
|
|
52
|
+
* as JSON drops it, rather than handed to marshallJsonValue, which
|
|
53
|
+
* refuses one and would fail the write (a login answering 500) only when
|
|
54
|
+
* compression is off.
|
|
55
|
+
*
|
|
56
|
+
* Data too large for a record is refused here, before anything is
|
|
57
|
+
* written, with the size and the limit it passed: DynamoDB would refuse
|
|
58
|
+
* the item with a ValidationException that names neither, and data past
|
|
59
|
+
* the restore ceiling would be written only to read back as no session.
|
|
60
|
+
* Create and update measure the same attributes, so data one accepts the
|
|
61
|
+
* other does too.
|
|
54
62
|
*/
|
|
55
63
|
private dataAttributes;
|
|
56
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The item for a record: the two hashes under the table's key names, the
|
|
66
|
+
* record's other fields under their own names (strings and numbers, an
|
|
67
|
+
* absent dataExpiresAt left out), the data plain or compressed. The
|
|
68
|
+
* attribute names and types are the ones items have always carried, so
|
|
69
|
+
* records written before and after read alike.
|
|
70
|
+
*/
|
|
57
71
|
private toItem;
|
|
58
72
|
/**
|
|
59
73
|
* The record for an item. A compressed record decodes back into `data`;
|
|
60
|
-
* one whose data cannot be decoded
|
|
61
|
-
* like a record
|
|
74
|
+
* one whose data cannot be decoded, or declares more of it than the store
|
|
75
|
+
* ever writes, is malformed and reads as no session, like a record
|
|
76
|
+
* missing its csrfTokenHash.
|
|
62
77
|
*
|
|
63
78
|
* This is where read failures and malformed records separate. A read
|
|
64
79
|
* failure is infrastructure and must surface as a 500: signing somebody
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { attributeValueBytes, createDynamoClientLoader, isConditionalCheckFailure, marshallJsonValue, MAX_ITEM_BYTES, storedNumber, unmarshallJsonValue, } from "./LambderDdbSdk.js";
|
|
2
|
+
import { restoreStoredText, storedTextOf } from "./LambderStoredText.js";
|
|
3
3
|
import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
|
|
4
4
|
/** The fields besides data an update may write, as the manager names them. */
|
|
5
5
|
const UPDATABLE_FIELDS = ["dataExpiresAt", "lastAccessedAt", "expiresAt"];
|
|
@@ -7,11 +7,30 @@ const UPDATABLE_FIELDS = ["dataExpiresAt", "lastAccessedAt", "expiresAt"];
|
|
|
7
7
|
* Session compression defaults: every record compressed (see
|
|
8
8
|
* LambderCompressionOption for the option's shape and toggle semantics).
|
|
9
9
|
* A compressed record carries the data's JSON as Brotli bytes (`dataBr`)
|
|
10
|
-
* beside its byte length (`dataBytes`), the scheme
|
|
11
|
-
*
|
|
12
|
-
* record keeps a plain `data` attribute.
|
|
10
|
+
* beside its byte length (`dataBytes`), the scheme every DynamoDB store here
|
|
11
|
+
* keeps text in (see LambderStoredText); below minBytes, or with compression
|
|
12
|
+
* off, the record keeps a plain `data` attribute.
|
|
13
13
|
*/
|
|
14
14
|
const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
15
|
+
/**
|
|
16
|
+
* The longest session.data a record holds, in UTF-8 bytes of its JSON: the
|
|
17
|
+
* most the store writes, and so the ceiling it restores a compressed record
|
|
18
|
+
* under. Far past what a session should carry, since it is read on every
|
|
19
|
+
* request, and the same 32 MiB the cache and the idempotency store keep.
|
|
20
|
+
*/
|
|
21
|
+
const DATA_CEILING = { user: "LambderDdbSessionStore", maxTextBytes: 32 * 1024 * 1024 };
|
|
22
|
+
/**
|
|
23
|
+
* What session.data may take of a record as stored, sized as DynamoDB sizes
|
|
24
|
+
* an item: its item limit less 8 KB for the record's other attributes (three
|
|
25
|
+
* hashes, the session key and a handful of numbers, a few hundred bytes with
|
|
26
|
+
* any session key of sensible length).
|
|
27
|
+
*/
|
|
28
|
+
const MAX_STORED_DATA_BYTES = MAX_ITEM_BYTES - 8 * 1024;
|
|
29
|
+
/** A number attribute's value, or undefined when the attribute is missing or holds no finite number. */
|
|
30
|
+
const numberAttributeOf = (attribute) => {
|
|
31
|
+
const value = attribute?.N === undefined ? NaN : Number(attribute.N);
|
|
32
|
+
return Number.isFinite(value) ? value : undefined;
|
|
33
|
+
};
|
|
15
34
|
/**
|
|
16
35
|
* Sessions at rest in DynamoDB: one item per session under the two hashes,
|
|
17
36
|
* with session.data Brotli-compressed by default. The store maps the
|
|
@@ -42,40 +61,66 @@ export class LambderDdbSessionStore {
|
|
|
42
61
|
this.partitionKey = options.partitionKey ?? "pk";
|
|
43
62
|
this.sortKey = options.sortKey ?? "sk";
|
|
44
63
|
this.compression = resolveCompressionOption(options.compression, SESSION_COMPRESSION_DEFAULTS);
|
|
45
|
-
this.ready =
|
|
46
|
-
user: "LambderDdbSessionStore",
|
|
47
|
-
...(options.region !== undefined ? { region: options.region } : {}),
|
|
48
|
-
...(options.client ? { client: options.client } : {}),
|
|
49
|
-
});
|
|
64
|
+
this.ready = createDynamoClientLoader({ user: "LambderDdbSessionStore", region: options.region, client: options.client });
|
|
50
65
|
}
|
|
51
66
|
keyOf(sessionKeyHash, secretHash) {
|
|
52
|
-
return { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash };
|
|
67
|
+
return { [this.partitionKey]: { S: sessionKeyHash }, [this.sortKey]: { S: secretHash } };
|
|
53
68
|
}
|
|
54
69
|
/**
|
|
55
70
|
* session.data as the attributes that hold it: `dataBr` and `dataBytes`
|
|
56
|
-
* when compressed, a plain `data` otherwise. Either way it goes
|
|
57
|
-
* its JSON first, so a plain record holds exactly what a
|
|
58
|
-
* restores to: an `undefined` inside the data is dropped
|
|
59
|
-
*
|
|
60
|
-
* write (a login answering 500) only when
|
|
71
|
+
* when compressed, a plain `data` attribute otherwise. Either way it goes
|
|
72
|
+
* through its JSON first, so a plain record holds exactly what a
|
|
73
|
+
* compressed one restores to: an `undefined` inside the data is dropped,
|
|
74
|
+
* as JSON drops it, rather than handed to marshallJsonValue, which
|
|
75
|
+
* refuses one and would fail the write (a login answering 500) only when
|
|
76
|
+
* compression is off.
|
|
77
|
+
*
|
|
78
|
+
* Data too large for a record is refused here, before anything is
|
|
79
|
+
* written, with the size and the limit it passed: DynamoDB would refuse
|
|
80
|
+
* the item with a ValidationException that names neither, and data past
|
|
81
|
+
* the restore ceiling would be written only to read back as no session.
|
|
82
|
+
* Create and update measure the same attributes, so data one accepts the
|
|
83
|
+
* other does too.
|
|
61
84
|
*/
|
|
62
85
|
async dataAttributes(data) {
|
|
63
86
|
const json = JSON.stringify(data);
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
|
|
87
|
+
const utf8 = Buffer.from(json, "utf8");
|
|
88
|
+
if (utf8.byteLength > DATA_CEILING.maxTextBytes) {
|
|
89
|
+
throw new Error(`LambderDdbSessionStore: session.data is ${utf8.byteLength} bytes of JSON, over the ${DATA_CEILING.maxTextBytes}-byte limit of a session record. Keep less in the session.`);
|
|
67
90
|
}
|
|
68
|
-
|
|
91
|
+
const text = await storedTextOf(utf8, this.compression);
|
|
92
|
+
const attributes = text.encoding === "br"
|
|
93
|
+
? { dataBr: { B: text.stored }, dataBytes: { N: String(text.textBytes) } }
|
|
94
|
+
: { data: marshallJsonValue(JSON.parse(json)) };
|
|
95
|
+
const storedBytes = Object.entries(attributes)
|
|
96
|
+
.reduce((total, [name, value]) => total + Buffer.byteLength(name, "utf8") + attributeValueBytes(value), 0);
|
|
97
|
+
if (storedBytes > MAX_STORED_DATA_BYTES) {
|
|
98
|
+
const remedy = text.encoding === "br" ? "Keep less in the session." : "Keep less in the session, or let the store's compression option compress it.";
|
|
99
|
+
throw new Error(`LambderDdbSessionStore: session.data is ${storedBytes} bytes as stored, over the ${MAX_STORED_DATA_BYTES} bytes a session record leaves it inside DynamoDB's ${MAX_ITEM_BYTES}-byte item limit. ${remedy}`);
|
|
100
|
+
}
|
|
101
|
+
return attributes;
|
|
69
102
|
}
|
|
70
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The item for a record: the two hashes under the table's key names, the
|
|
105
|
+
* record's other fields under their own names (strings and numbers, an
|
|
106
|
+
* absent dataExpiresAt left out), the data plain or compressed. The
|
|
107
|
+
* attribute names and types are the ones items have always carried, so
|
|
108
|
+
* records written before and after read alike.
|
|
109
|
+
*/
|
|
71
110
|
async toItem(record) {
|
|
72
|
-
const { sessionKeyHash, secretHash, data, ...
|
|
73
|
-
|
|
111
|
+
const { sessionKeyHash, secretHash, data, ...fields } = record;
|
|
112
|
+
const item = { ...this.keyOf(sessionKeyHash, secretHash), ...await this.dataAttributes(data) };
|
|
113
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
114
|
+
if (value !== undefined)
|
|
115
|
+
item[name] = marshallJsonValue(value);
|
|
116
|
+
}
|
|
117
|
+
return item;
|
|
74
118
|
}
|
|
75
119
|
/**
|
|
76
120
|
* The record for an item. A compressed record decodes back into `data`;
|
|
77
|
-
* one whose data cannot be decoded
|
|
78
|
-
* like a record
|
|
121
|
+
* one whose data cannot be decoded, or declares more of it than the store
|
|
122
|
+
* ever writes, is malformed and reads as no session, like a record
|
|
123
|
+
* missing its csrfTokenHash.
|
|
79
124
|
*
|
|
80
125
|
* This is where read failures and malformed records separate. A read
|
|
81
126
|
* failure is infrastructure and must surface as a 500: signing somebody
|
|
@@ -85,44 +130,60 @@ export class LambderDdbSessionStore {
|
|
|
85
130
|
* until the TTL retires it. Ending it lets them log in again.
|
|
86
131
|
*/
|
|
87
132
|
async fromItem(item) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
const compressed = item.dataBr?.B;
|
|
136
|
+
if (compressed)
|
|
137
|
+
data = JSON.parse(await restoreStoredText(compressed, storedNumber(item.dataBytes?.N, 0), DATA_CEILING));
|
|
138
|
+
else if (item.data)
|
|
139
|
+
data = unmarshallJsonValue(item.data);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.warn(`LambderDdbSessionStore: a session record in "${this.tableName}" could not be decoded, so it reads as no session.`, err);
|
|
143
|
+
return null;
|
|
98
144
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
const sessionKeyHash = item[this.partitionKey]?.S;
|
|
146
|
+
const secretHash = item[this.sortKey]?.S;
|
|
147
|
+
const csrfTokenHash = item.csrfTokenHash?.S;
|
|
148
|
+
const sessionKey = item.sessionKey?.S;
|
|
149
|
+
const createdAt = numberAttributeOf(item.createdAt);
|
|
150
|
+
const expiresAt = numberAttributeOf(item.expiresAt);
|
|
151
|
+
const lastAccessedAt = numberAttributeOf(item.lastAccessedAt);
|
|
152
|
+
const ttlInSeconds = numberAttributeOf(item.ttlInSeconds);
|
|
153
|
+
const dataVersion = numberAttributeOf(item.dataVersion);
|
|
154
|
+
const dataExpiresAt = numberAttributeOf(item.dataExpiresAt);
|
|
155
|
+
// The load-bearing fields are checked before the record is built,
|
|
156
|
+
// because everything past this line trusts them: the manager compares
|
|
157
|
+
// the two hashes in constant time (a non-string would throw there
|
|
158
|
+
// rather than answer false) and reads expiresAt as a number to decide
|
|
159
|
+
// whether the session is over. An item missing them is not this
|
|
160
|
+
// store's record (written by an earlier major, by hand, or by another
|
|
161
|
+
// app sharing the table), and it reads as no session for the same
|
|
162
|
+
// reason an undecodable one does: it will not become valid later. Not
|
|
163
|
+
// logged: a visitor whose cookie names such an item sends it on every
|
|
164
|
+
// request until the cookie expires, and the item itself goes with its
|
|
165
|
+
// TTL. dataVersion is load-bearing the same way: every conditioned
|
|
166
|
+
// write names the one read, and a record without it would fail that
|
|
167
|
+
// write rather than answer "stale".
|
|
168
|
+
if (sessionKeyHash === undefined || secretHash === undefined || csrfTokenHash === undefined || sessionKey === undefined
|
|
169
|
+
|| createdAt === undefined || expiresAt === undefined || ttlInSeconds === undefined || dataVersion === undefined) {
|
|
116
170
|
return null;
|
|
117
171
|
}
|
|
118
|
-
// The
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
172
|
+
// The cast: session.data is whatever the app put there, and JSON (or
|
|
173
|
+
// the plain attribute) hands it back untyped. Nothing in the store can
|
|
174
|
+
// check it, because the shape is the app's, not this layer's. The
|
|
175
|
+
// other unchecked field is lastAccessedAt: written with every record,
|
|
176
|
+
// and read as absent from an item without it rather than refusing it.
|
|
177
|
+
return {
|
|
178
|
+
sessionKeyHash, secretHash, csrfTokenHash, sessionKey, data: data,
|
|
179
|
+
createdAt, expiresAt, ttlInSeconds, dataVersion,
|
|
180
|
+
...(lastAccessedAt !== undefined ? { lastAccessedAt } : {}),
|
|
181
|
+
...(dataExpiresAt !== undefined ? { dataExpiresAt } : {}),
|
|
182
|
+
};
|
|
122
183
|
}
|
|
123
184
|
async get(sessionKeyHash, secretHash) {
|
|
124
185
|
const { client, sdk } = await this.ready();
|
|
125
|
-
const response = await client.send(new sdk.
|
|
186
|
+
const response = await client.send(new sdk.GetItemCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ConsistentRead: true }));
|
|
126
187
|
if (!response.Item)
|
|
127
188
|
return null;
|
|
128
189
|
return await this.fromItem(response.Item);
|
|
@@ -130,7 +191,7 @@ export class LambderDdbSessionStore {
|
|
|
130
191
|
async create(record) {
|
|
131
192
|
const item = await this.toItem(record);
|
|
132
193
|
const { client, sdk } = await this.ready();
|
|
133
|
-
await client.send(new sdk.
|
|
194
|
+
await client.send(new sdk.PutItemCommand({
|
|
134
195
|
TableName: this.tableName,
|
|
135
196
|
Item: item,
|
|
136
197
|
ConditionExpression: "attribute_not_exists(#sk)",
|
|
@@ -148,8 +209,9 @@ export class LambderDdbSessionStore {
|
|
|
148
209
|
const attributes = await this.dataAttributes(changes.data);
|
|
149
210
|
for (const attribute of ["data", "dataBr", "dataBytes"]) {
|
|
150
211
|
names[`#${attribute}`] = attribute;
|
|
151
|
-
|
|
152
|
-
|
|
212
|
+
const value = attributes[attribute];
|
|
213
|
+
if (value) {
|
|
214
|
+
values[`:${attribute}`] = value;
|
|
153
215
|
set.push(`#${attribute} = :${attribute}`);
|
|
154
216
|
}
|
|
155
217
|
else {
|
|
@@ -161,25 +223,25 @@ export class LambderDdbSessionStore {
|
|
|
161
223
|
if (changes[field] === undefined)
|
|
162
224
|
continue;
|
|
163
225
|
names[`#${field}`] = field;
|
|
164
|
-
values[`:${field}`] = changes[field];
|
|
226
|
+
values[`:${field}`] = marshallJsonValue(changes[field]);
|
|
165
227
|
set.push(`#${field} = :${field}`);
|
|
166
228
|
}
|
|
167
229
|
// A write of the data or its deadline moves the version in the same
|
|
168
230
|
// write, whatever value it writes (see LambderSessionStore.update).
|
|
169
231
|
if ("data" in changes || changes.dataExpiresAt !== undefined) {
|
|
170
232
|
names["#dataVersion"] = "dataVersion";
|
|
171
|
-
values[":dataVersionStep"] = 1;
|
|
233
|
+
values[":dataVersionStep"] = { N: "1" };
|
|
172
234
|
add.push("#dataVersion :dataVersionStep");
|
|
173
235
|
}
|
|
174
236
|
let conditionExpression = "attribute_exists(#sk)";
|
|
175
237
|
if (condition) {
|
|
176
238
|
names["#dataVersion"] = "dataVersion";
|
|
177
|
-
values[":readDataVersion"] = condition.dataVersion;
|
|
239
|
+
values[":readDataVersion"] = marshallJsonValue(condition.dataVersion);
|
|
178
240
|
conditionExpression += " AND #dataVersion = :readDataVersion";
|
|
179
241
|
}
|
|
180
242
|
try {
|
|
181
243
|
const { client, sdk } = await this.ready();
|
|
182
|
-
await client.send(new sdk.
|
|
244
|
+
await client.send(new sdk.UpdateItemCommand({
|
|
183
245
|
TableName: this.tableName,
|
|
184
246
|
Key: this.keyOf(sessionKeyHash, secretHash),
|
|
185
247
|
UpdateExpression: [
|
|
@@ -205,7 +267,7 @@ export class LambderDdbSessionStore {
|
|
|
205
267
|
}
|
|
206
268
|
async delete(sessionKeyHash, secretHash) {
|
|
207
269
|
const { client, sdk } = await this.ready();
|
|
208
|
-
const response = await client.send(new sdk.
|
|
270
|
+
const response = await client.send(new sdk.DeleteItemCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ReturnValues: "ALL_OLD" }));
|
|
209
271
|
return response.Attributes ? await this.fromItem(response.Attributes) : null;
|
|
210
272
|
}
|
|
211
273
|
async listSecretHashes(sessionKeyHash) {
|
|
@@ -214,7 +276,7 @@ export class LambderDdbSessionStore {
|
|
|
214
276
|
KeyConditionExpression: "#pk = :pv",
|
|
215
277
|
ProjectionExpression: "#sk",
|
|
216
278
|
ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
|
|
217
|
-
ExpressionAttributeValues: { ":pv": sessionKeyHash },
|
|
279
|
+
ExpressionAttributeValues: { ":pv": { S: sessionKeyHash } },
|
|
218
280
|
// Consistent: "log out everywhere" has to find a session created
|
|
219
281
|
// a moment before it, and an eventually consistent read may not.
|
|
220
282
|
ConsistentRead: true,
|
|
@@ -223,8 +285,11 @@ export class LambderDdbSessionStore {
|
|
|
223
285
|
for (;;) {
|
|
224
286
|
const { client, sdk } = await this.ready();
|
|
225
287
|
const { Items, LastEvaluatedKey } = await client.send(new sdk.QueryCommand(params));
|
|
226
|
-
for (const item of Items ?? [])
|
|
227
|
-
|
|
288
|
+
for (const item of Items ?? []) {
|
|
289
|
+
const secretHash = item[this.sortKey]?.S;
|
|
290
|
+
if (secretHash !== undefined)
|
|
291
|
+
hashes.push(secretHash);
|
|
292
|
+
}
|
|
228
293
|
if (LastEvaluatedKey === undefined)
|
|
229
294
|
return hashes;
|
|
230
295
|
params.ExclusiveStartKey = LastEvaluatedKey;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
|
|
1
|
+
import type { LambderCache, LambderCacheGetOrSetOptions, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
|
|
2
2
|
export interface LambderMemoryCacheOptions {
|
|
3
3
|
/** Default: one year, as LambderDdbCache's. */
|
|
4
4
|
defaultTtlSeconds?: number;
|
|
@@ -41,7 +41,7 @@ export declare class LambderMemoryCache implements LambderCache {
|
|
|
41
41
|
delete(key: LambderCacheKey): Promise<boolean>;
|
|
42
42
|
deletePartition(partition: string): Promise<number>;
|
|
43
43
|
listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
|
|
44
|
-
getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?:
|
|
44
|
+
getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheGetOrSetOptions): Promise<T>;
|
|
45
45
|
/** Forgets every entry; a fill in flight meanwhile stores nothing. */
|
|
46
46
|
reset(): void;
|
|
47
47
|
/** Stores the value and hands back what was stored, the parse of its JSON. */
|
|
@@ -43,8 +43,9 @@ export declare class LambderS3FileSource implements LambderFileSource {
|
|
|
43
43
|
private readonly clientConfig;
|
|
44
44
|
private readonly notFoundErrorNames;
|
|
45
45
|
private client;
|
|
46
|
-
private
|
|
46
|
+
private clientSdk;
|
|
47
47
|
constructor({ bucket, prefix, client, clientConfig, notFoundErrorNames }: LambderS3FileSourceOptions);
|
|
48
|
-
private loadSdk;
|
|
49
48
|
read(relativePath: string): Promise<LambderFile | null>;
|
|
49
|
+
/** The SDK, loaded on the first read, and the client: the one supplied, or one made from clientConfig. */
|
|
50
|
+
private s3;
|
|
50
51
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
|
|
2
|
+
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
2
3
|
const DEFAULT_NOT_FOUND_ERROR_NAMES = ["NoSuchKey", "NotFound", "AccessDenied", "KeyTooLongError", "InvalidURI", "InvalidObjectName"];
|
|
3
4
|
/**
|
|
4
5
|
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
@@ -18,7 +19,7 @@ export class LambderS3FileSource {
|
|
|
18
19
|
clientConfig;
|
|
19
20
|
notFoundErrorNames;
|
|
20
21
|
client;
|
|
21
|
-
|
|
22
|
+
clientSdk;
|
|
22
23
|
constructor({ bucket, prefix = "", client, clientConfig, notFoundErrorNames = DEFAULT_NOT_FOUND_ERROR_NAMES }) {
|
|
23
24
|
if (!bucket.trim())
|
|
24
25
|
throw new Error("bucket is required");
|
|
@@ -28,21 +29,11 @@ export class LambderS3FileSource {
|
|
|
28
29
|
this.clientConfig = clientConfig;
|
|
29
30
|
this.notFoundErrorNames = notFoundErrorNames;
|
|
30
31
|
}
|
|
31
|
-
loadSdk() {
|
|
32
|
-
if (!this.sdk) {
|
|
33
|
-
this.sdk = import("@aws-sdk/client-s3").catch(() => {
|
|
34
|
-
throw new Error("LambderS3FileSource requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3");
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
return this.sdk;
|
|
38
|
-
}
|
|
39
32
|
async read(relativePath) {
|
|
40
|
-
const {
|
|
41
|
-
if (!this.client)
|
|
42
|
-
this.client = new S3Client(this.clientConfig ?? {});
|
|
33
|
+
const { sdk, client } = await this.s3();
|
|
43
34
|
let output;
|
|
44
35
|
try {
|
|
45
|
-
output = await
|
|
36
|
+
output = await client.send(new sdk.GetObjectCommand({ Bucket: this.bucket, Key: `${this.prefix}${relativePath}` }));
|
|
46
37
|
}
|
|
47
38
|
catch (err) {
|
|
48
39
|
const name = err.name;
|
|
@@ -54,4 +45,11 @@ export class LambderS3FileSource {
|
|
|
54
45
|
return null;
|
|
55
46
|
return remoteStoreFile(Buffer.from(await output.Body.transformToByteArray()), output.ContentType);
|
|
56
47
|
}
|
|
48
|
+
/** The SDK, loaded on the first read, and the client: the one supplied, or one made from clientConfig. */
|
|
49
|
+
async s3() {
|
|
50
|
+
this.clientSdk ??= withInstallHint(import("@aws-sdk/client-s3"), "@aws-sdk/client-s3", "LambderS3FileSource", () => { this.clientSdk = undefined; });
|
|
51
|
+
const sdk = await this.clientSdk;
|
|
52
|
+
this.client ??= new sdk.S3Client(this.clientConfig ?? {});
|
|
53
|
+
return { sdk, client: this.client };
|
|
54
|
+
}
|
|
57
55
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
2
|
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadMethod, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
3
|
+
import type { LambderMemoryUploadBucketOptions } from "./LambderMemoryUploadBucket.js";
|
|
4
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
3
5
|
export type LambderS3UploadBucketOptions = {
|
|
4
6
|
bucket: string;
|
|
5
7
|
/** A ready client, e.g. one shared with the rest of the app. */
|
|
@@ -50,6 +52,16 @@ export declare class LambderS3UploadBucket implements LambderUploadBucket {
|
|
|
50
52
|
private presignedPostSdk;
|
|
51
53
|
private requestPresignerSdk;
|
|
52
54
|
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds, uploadMethod }: LambderS3UploadBucketOptions);
|
|
55
|
+
/**
|
|
56
|
+
* Puts a memory twin under this bucket in place, for `lambder/testing`:
|
|
57
|
+
* every LambderUploadBucket member answers from the twin from then on.
|
|
58
|
+
* The twin signs with this bucket's own lifetimes and upload method, so a
|
|
59
|
+
* client takes the path against it that it takes against this bucket.
|
|
60
|
+
* Keyed by a symbol no entry point exports; see registerSwappableInstance.
|
|
61
|
+
*/
|
|
62
|
+
[LAMBDER_BACKEND_SWAP](twins: {
|
|
63
|
+
uploadBucket(options: LambderMemoryUploadBucketOptions): LambderUploadBucket;
|
|
64
|
+
}): void;
|
|
53
65
|
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
54
66
|
objectKey: string;
|
|
55
67
|
fileFacts: LambderUploadFileFacts;
|
|
@@ -3,8 +3,15 @@ import { contentDispositionHeader } from "../shared/util/LambderContentDispositi
|
|
|
3
3
|
import { uploadObjectFormFields, uploadObjectHeaders } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
4
|
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
5
5
|
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
6
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
7
|
+
import { delegateToTwin, registerSwappableInstance } from "../shared/util/LambderSwappableInstances.js";
|
|
6
8
|
/** The S3 error names that mean nothing is stored under the key: HeadObject answers NotFound, the other calls NoSuchKey. */
|
|
7
9
|
const MISSING_OBJECT_ERROR_NAMES = ["NotFound", "NoSuchKey"];
|
|
10
|
+
/** Every LambderUploadBucket member, which the swap door hands to a memory twin. */
|
|
11
|
+
const UPLOAD_BUCKET_MEMBERS = {
|
|
12
|
+
issueUploadTicket: true, verifyUploadedObject: true, issueDownloadUrl: true,
|
|
13
|
+
readObject: true, writeObject: true, copyObject: true, deleteObject: true,
|
|
14
|
+
};
|
|
8
15
|
/**
|
|
9
16
|
* An S3 bucket browsers upload to directly (see LambderUploadBucket).
|
|
10
17
|
*
|
|
@@ -46,6 +53,22 @@ export class LambderS3UploadBucket {
|
|
|
46
53
|
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
47
54
|
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
48
55
|
this.uploadMethod = uploadMethod;
|
|
56
|
+
registerSwappableInstance(this);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Puts a memory twin under this bucket in place, for `lambder/testing`:
|
|
60
|
+
* every LambderUploadBucket member answers from the twin from then on.
|
|
61
|
+
* The twin signs with this bucket's own lifetimes and upload method, so a
|
|
62
|
+
* client takes the path against it that it takes against this bucket.
|
|
63
|
+
* Keyed by a symbol no entry point exports; see registerSwappableInstance.
|
|
64
|
+
*/
|
|
65
|
+
[LAMBDER_BACKEND_SWAP](twins) {
|
|
66
|
+
const twin = twins.uploadBucket({
|
|
67
|
+
ticketLifetimeSeconds: this.ticketLifetimeSeconds,
|
|
68
|
+
downloadLifetimeSeconds: this.downloadLifetimeSeconds,
|
|
69
|
+
uploadMethod: this.uploadMethod,
|
|
70
|
+
});
|
|
71
|
+
delegateToTwin(this, twin, UPLOAD_BUCKET_MEMBERS);
|
|
49
72
|
}
|
|
50
73
|
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object }) {
|
|
51
74
|
assertPinnedObjectKey(objectKey);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
|
|
1
|
+
import type { LambderCache, LambderCacheGetOrSetOptions, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
|
|
2
2
|
import { type LambderCacheAddress } from "./LambderCacheKeys.js";
|
|
3
3
|
/** One entry as a storage keeps it: the value's JSON text and the second it stops being live. */
|
|
4
4
|
export interface LambderCacheStoredEntry {
|
|
@@ -85,7 +85,7 @@ export declare class LambderStorageBackedCache implements LambderCache {
|
|
|
85
85
|
delete(key: LambderCacheKey): Promise<boolean>;
|
|
86
86
|
deletePartition(partition: string): Promise<number>;
|
|
87
87
|
listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
|
|
88
|
-
getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?:
|
|
88
|
+
getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheGetOrSetOptions): Promise<T>;
|
|
89
89
|
/** Whole seconds, as entries expire in every cache here, so a TTL ends on the same second as the memory cache's. */
|
|
90
90
|
private nowSeconds;
|
|
91
91
|
/** The entry at the address while it is live; the storage may still hold one past its expiry. */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LambderCompressionSettings } from "../shared/wire/LambderCompressionOption.js";
|
|
2
|
+
/** Text as a store keeps it: `stored` is the Brotli bytes ("br") or the UTF-8 bytes themselves ("identity"). */
|
|
3
|
+
export interface LambderStoredText {
|
|
4
|
+
encoding: "br" | "identity";
|
|
5
|
+
stored: Buffer;
|
|
6
|
+
/** The text's UTF-8 byte length: the declared length a compressed text is restored under. */
|
|
7
|
+
textBytes: number;
|
|
8
|
+
}
|
|
9
|
+
/** What a store restores stored text under: the most it ever writes, and its own name for the refusal. */
|
|
10
|
+
export interface LambderStoredTextCeiling {
|
|
11
|
+
user: string;
|
|
12
|
+
maxTextBytes: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The text's UTF-8 bytes as the store keeps them, under its compression
|
|
16
|
+
* settings (null: compression off). The plain form is the bytes handed in,
|
|
17
|
+
* not a copy.
|
|
18
|
+
*/
|
|
19
|
+
export declare const storedTextOf: (utf8: Buffer, compression: LambderCompressionSettings | null) => Promise<LambderStoredText>;
|
|
20
|
+
/**
|
|
21
|
+
* The text a compressed record holds, restored under the length it declares,
|
|
22
|
+
* or a throw: a plain Error naming the store when the declared length is over
|
|
23
|
+
* its ceiling, and the codec's LambderCompressionError when the length is
|
|
24
|
+
* missing or the bytes do not restore to exactly that length.
|
|
25
|
+
*/
|
|
26
|
+
export declare const restoreStoredText: (compressed: Uint8Array, declaredBytes: number, ceiling: LambderStoredTextCeiling) => Promise<string>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
|
|
2
|
+
/**
|
|
3
|
+
* The text's UTF-8 bytes as the store keeps them, under its compression
|
|
4
|
+
* settings (null: compression off). The plain form is the bytes handed in,
|
|
5
|
+
* not a copy.
|
|
6
|
+
*/
|
|
7
|
+
export const storedTextOf = async (utf8, compression) => {
|
|
8
|
+
const textBytes = utf8.byteLength;
|
|
9
|
+
if (compression && textBytes > 0 && textBytes >= compression.minBytes) {
|
|
10
|
+
return { encoding: "br", stored: await compressText(utf8, "br", compression.quality), textBytes };
|
|
11
|
+
}
|
|
12
|
+
return { encoding: "identity", stored: utf8, textBytes };
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The text a compressed record holds, restored under the length it declares,
|
|
16
|
+
* or a throw: a plain Error naming the store when the declared length is over
|
|
17
|
+
* its ceiling, and the codec's LambderCompressionError when the length is
|
|
18
|
+
* missing or the bytes do not restore to exactly that length.
|
|
19
|
+
*/
|
|
20
|
+
export const restoreStoredText = async (compressed, declaredBytes, ceiling) => {
|
|
21
|
+
if (declaredBytes > ceiling.maxTextBytes) {
|
|
22
|
+
throw new Error(`${ceiling.user}: a stored record declares ${declaredBytes} bytes of text, over the ${ceiling.maxTextBytes}-byte limit it restores, so the record is unusable.`);
|
|
23
|
+
}
|
|
24
|
+
return await restoreText(compressed, "br", { declaredBytes });
|
|
25
|
+
};
|
|
@@ -38,8 +38,15 @@ export type LambderConformanceRunner = {
|
|
|
38
38
|
export type LambderConformanceSetup = {
|
|
39
39
|
now: () => number;
|
|
40
40
|
};
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Where every case's clock starts: 2100-01-01T00:00:00Z. Fixed, so the
|
|
43
|
+
* records a case writes are the same on every run, and far in the future, so
|
|
44
|
+
* a storage that also hands its records to a native TTL (a database that
|
|
45
|
+
* deletes expired rows itself, Redis's EXPIREAT) holds every one a case
|
|
46
|
+
* writes, and a record counts as expired only when the store says so by the
|
|
47
|
+
* case's clock. In seconds it still fits 32 bits unsigned.
|
|
48
|
+
*/
|
|
49
|
+
export declare const CONFORMANCE_START_MILLIS = 4102444800000;
|
|
43
50
|
/** The clock of one case: `now` for the store, `set` for the case. */
|
|
44
51
|
export declare const conformanceClock: () => LambderConformanceSetup & {
|
|
45
52
|
set(millis: number): void;
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
* under vitest, jest, or any runner with a jest-style `expect`, and the
|
|
10
10
|
* package depends on none of them.
|
|
11
11
|
*/
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Where every case's clock starts: 2100-01-01T00:00:00Z. Fixed, so the
|
|
14
|
+
* records a case writes are the same on every run, and far in the future, so
|
|
15
|
+
* a storage that also hands its records to a native TTL (a database that
|
|
16
|
+
* deletes expired rows itself, Redis's EXPIREAT) holds every one a case
|
|
17
|
+
* writes, and a record counts as expired only when the store says so by the
|
|
18
|
+
* case's clock. In seconds it still fits 32 bits unsigned.
|
|
19
|
+
*/
|
|
20
|
+
export const CONFORMANCE_START_MILLIS = 4_102_444_800_000;
|
|
14
21
|
/** The clock of one case: `now` for the store, `set` for the case. */
|
|
15
22
|
export const conformanceClock = () => {
|
|
16
23
|
let millis = CONFORMANCE_START_MILLIS;
|