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
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { CONFORMANCE_START_MILLIS as START, conformanceClock, } from "./LambderConformanceRunner.js";
|
|
2
|
+
/** A loader that answers only once the case opens its gate, and lets the case wait until it has been called. */
|
|
3
|
+
const gatedLoader = (value) => {
|
|
4
|
+
let open = () => undefined;
|
|
5
|
+
let markStarted = () => undefined;
|
|
6
|
+
const gate = new Promise((resolve) => { open = resolve; });
|
|
7
|
+
const started = new Promise((resolve) => { markStarted = resolve; });
|
|
8
|
+
let calls = 0;
|
|
9
|
+
const loader = async () => {
|
|
10
|
+
calls += 1;
|
|
11
|
+
markStarted();
|
|
12
|
+
await gate;
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
return { loader, started, open: () => open(), calls: () => calls };
|
|
16
|
+
};
|
|
17
|
+
/** A loader that counts its calls. */
|
|
18
|
+
const countingLoader = (answer) => {
|
|
19
|
+
let calls = 0;
|
|
20
|
+
return { loader: async () => { calls += 1; return answer(); }, calls: () => calls };
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Registers the cache rules as cases of the runner, one `it` each, against
|
|
24
|
+
* the cache `create` builds:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { describe, it, expect } from "vitest";
|
|
28
|
+
* import { lambderCacheConformance } from "lambder/testing";
|
|
29
|
+
*
|
|
30
|
+
* describe("RedisCache", () => {
|
|
31
|
+
* lambderCacheConformance({ it, expect, create: async ({ now }) => { await emptyCache(); return new RedisCache({ client, now }); } });
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* The entries a case writes expire by the case's clock, which starts at a
|
|
36
|
+
* fixed moment far in the future and stands still unless a case moves it: a
|
|
37
|
+
* cache that expires entries by the system clock, rather than by `now`, never
|
|
38
|
+
* sees one expire. A cache over a storage with a native TTL keeps it on, since
|
|
39
|
+
* nothing a case writes has expired by the world's clock.
|
|
40
|
+
*/
|
|
41
|
+
export const lambderCacheConformance = (options) => {
|
|
42
|
+
const { it, expect } = options;
|
|
43
|
+
const begin = async () => {
|
|
44
|
+
const clock = conformanceClock();
|
|
45
|
+
return { clock, cache: await options.create(clock) };
|
|
46
|
+
};
|
|
47
|
+
it("hands back a parse of what was stored, never the object itself", async () => {
|
|
48
|
+
const { cache } = await begin();
|
|
49
|
+
const value = { list: [1, 2], at: new Date(START) };
|
|
50
|
+
await cache.set("round-trip", value);
|
|
51
|
+
value.list.push(3);
|
|
52
|
+
const read = await cache.get("round-trip");
|
|
53
|
+
expect(read).toEqual({ list: [1, 2], at: new Date(START).toISOString() });
|
|
54
|
+
expect(await cache.get("round-trip")).not.toBe(read);
|
|
55
|
+
});
|
|
56
|
+
it("answers undefined for an absent key and false from has()", async () => {
|
|
57
|
+
const { cache } = await begin();
|
|
58
|
+
expect(await cache.get("absent")).toBeUndefined();
|
|
59
|
+
expect(await cache.has("absent")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
it("replaces an entry on a second set", async () => {
|
|
62
|
+
const { cache } = await begin();
|
|
63
|
+
await cache.set("order:1001", { status: "placed" });
|
|
64
|
+
await cache.set("order:1001", { status: "shipped" });
|
|
65
|
+
expect(await cache.get("order:1001")).toEqual({ status: "shipped" });
|
|
66
|
+
expect(await cache.has("order:1001")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("expires an entry at its TTL, the expiry second itself included", async () => {
|
|
69
|
+
const { clock, cache } = await begin();
|
|
70
|
+
await cache.set("short", "value", { ttlSeconds: 10 });
|
|
71
|
+
clock.set(START + 9_000);
|
|
72
|
+
expect(await cache.get("short")).toBe("value");
|
|
73
|
+
expect(await cache.has("short")).toBe(true);
|
|
74
|
+
clock.set(START + 10_000);
|
|
75
|
+
expect(await cache.get("short")).toBeUndefined();
|
|
76
|
+
expect(await cache.has("short")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it("refuses a value JSON cannot represent, a blank key, and a sort key past 900 bytes once escaped", async () => {
|
|
79
|
+
const { cache } = await begin();
|
|
80
|
+
await expect(cache.set("nothing", undefined)).rejects.toThrow();
|
|
81
|
+
await expect(cache.get(" ")).rejects.toThrow();
|
|
82
|
+
await expect(cache.get({ pk: "reports", sk: " " })).rejects.toThrow();
|
|
83
|
+
// "#" escapes to two bytes, so 500 of them are 1000 bytes escaped.
|
|
84
|
+
await expect(cache.get({ pk: "reports", sk: "#".repeat(500) })).rejects.toThrow();
|
|
85
|
+
});
|
|
86
|
+
it("deletes one entry and says whether there was one", async () => {
|
|
87
|
+
const { cache } = await begin();
|
|
88
|
+
await cache.set("gone", 1);
|
|
89
|
+
expect(await cache.delete("gone")).toBe(true);
|
|
90
|
+
expect(await cache.get("gone")).toBeUndefined();
|
|
91
|
+
expect(await cache.delete("gone")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
it("keeps a plain key and the grouped keys of its partition apart", async () => {
|
|
94
|
+
const { cache } = await begin();
|
|
95
|
+
await cache.set("store:nyc-01", "a plain key");
|
|
96
|
+
await cache.set({ pk: "store:nyc-01", sk: "hours" }, "a grouped key");
|
|
97
|
+
expect(await cache.delete("store:nyc-01")).toBe(true);
|
|
98
|
+
expect(await cache.get({ pk: "store:nyc-01", sk: "hours" })).toBe("a grouped key");
|
|
99
|
+
await cache.set("store:nyc-01", "a plain key");
|
|
100
|
+
expect(await cache.delete({ pk: "store:nyc-01", sk: "hours" })).toBe(true);
|
|
101
|
+
expect(await cache.get("store:nyc-01")).toBe("a plain key");
|
|
102
|
+
});
|
|
103
|
+
it("answers false from delete while a fill is loading the key, since a load in progress is no entry", async () => {
|
|
104
|
+
const { cache } = await begin();
|
|
105
|
+
const { loader, started, open } = gatedLoader({ tag: "old" });
|
|
106
|
+
const fill = cache.getOrSet("filling", loader);
|
|
107
|
+
await started;
|
|
108
|
+
expect(await cache.delete("filling")).toBe(false);
|
|
109
|
+
open();
|
|
110
|
+
expect(await fill).toEqual({ tag: "old" });
|
|
111
|
+
expect(await cache.get("filling")).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
it("counts no entry past its TTL as deleted, from delete or deletePartition", async () => {
|
|
114
|
+
const { clock, cache } = await begin();
|
|
115
|
+
await cache.set("short", 1, { ttlSeconds: 10 });
|
|
116
|
+
await cache.set({ pk: "store", sk: "short" }, 2, { ttlSeconds: 10 });
|
|
117
|
+
await cache.set({ pk: "store", sk: "long" }, 3, { ttlSeconds: 100 });
|
|
118
|
+
clock.set(START + 10_000);
|
|
119
|
+
expect(await cache.delete("short")).toBe(false);
|
|
120
|
+
expect(await cache.deletePartition("store")).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
it("lists a partition's sort keys in order, by prefix and up to a limit, and leaves plain keys out", async () => {
|
|
123
|
+
const { cache } = await begin();
|
|
124
|
+
await cache.set({ pk: "store", sk: "1800:1900" }, 3);
|
|
125
|
+
await cache.set({ pk: "store", sk: "1700:1800" }, 2);
|
|
126
|
+
await cache.set({ pk: "store", sk: "0900:1000" }, 1);
|
|
127
|
+
await cache.set({ pk: "other", sk: "1700:1800" }, 4);
|
|
128
|
+
await cache.set("store", "a plain key sharing the partition");
|
|
129
|
+
expect(await cache.listSortKeys("store")).toEqual(["0900:1000", "1700:1800", "1800:1900"]);
|
|
130
|
+
expect(await cache.listSortKeys("store", { prefix: "1" })).toEqual(["1700:1800", "1800:1900"]);
|
|
131
|
+
expect(await cache.listSortKeys("store", { limit: 1 })).toEqual(["0900:1000"]);
|
|
132
|
+
expect(await cache.listSortKeys("nothing-here")).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
it("lists only live sort keys", async () => {
|
|
135
|
+
const { clock, cache } = await begin();
|
|
136
|
+
await cache.set({ pk: "store", sk: "short" }, 1, { ttlSeconds: 10 });
|
|
137
|
+
await cache.set({ pk: "store", sk: "long" }, 2, { ttlSeconds: 100 });
|
|
138
|
+
clock.set(START + 10_000);
|
|
139
|
+
expect(await cache.listSortKeys("store")).toEqual(["long"]);
|
|
140
|
+
});
|
|
141
|
+
it("orders a key before one it is a prefix of when the longer one goes on below \"#\", as the table does", async () => {
|
|
142
|
+
const { cache } = await begin();
|
|
143
|
+
await cache.set({ pk: "cities", sk: "New York" }, 1);
|
|
144
|
+
await cache.set({ pk: "cities", sk: "New York City" }, 2);
|
|
145
|
+
await cache.set({ pk: "cities", sk: "New Yorker" }, 3);
|
|
146
|
+
expect(await cache.listSortKeys("cities")).toEqual(["New York City", "New York", "New Yorker"]);
|
|
147
|
+
expect(await cache.listSortKeys("cities", { prefix: "New", limit: 1 })).toEqual(["New York City"]);
|
|
148
|
+
});
|
|
149
|
+
it("orders keys holding \"#\" or \"~\" by their escaped form, as the table ranges them", async () => {
|
|
150
|
+
const { cache } = await begin();
|
|
151
|
+
await cache.set({ pk: "reports", sk: "a#b" }, 1);
|
|
152
|
+
await cache.set({ pk: "reports", sk: "a~b" }, 2);
|
|
153
|
+
await cache.set({ pk: "reports", sk: "a~1b" }, 3);
|
|
154
|
+
await cache.set({ pk: "reports", sk: "ab" }, 4);
|
|
155
|
+
// "~" escapes as "~0" and "#" as "~1", so both land past every letter.
|
|
156
|
+
expect(await cache.listSortKeys("reports")).toEqual(["ab", "a~1b", "a~b", "a#b"]);
|
|
157
|
+
expect(await cache.listSortKeys("reports", { prefix: "a~" })).toEqual(["a~1b", "a~b"]);
|
|
158
|
+
});
|
|
159
|
+
it("orders sort keys by their UTF-8 bytes, as DynamoDB orders a range key", async () => {
|
|
160
|
+
const { cache } = await begin();
|
|
161
|
+
// By bytes "B" (0x42) sorts before "a" (0x61), the opposite of a
|
|
162
|
+
// locale compare, and U+FF5E (0xEF ...) before U+1F600 (0xF0 ...),
|
|
163
|
+
// the opposite of UTF-16, which puts the surrogate pair first.
|
|
164
|
+
await cache.set({ pk: "p", sk: "a" }, 1);
|
|
165
|
+
await cache.set({ pk: "p", sk: "B" }, 2);
|
|
166
|
+
await cache.set({ pk: "p", sk: "\uff5e" }, 3);
|
|
167
|
+
await cache.set({ pk: "p", sk: "\u{1f600}" }, 4);
|
|
168
|
+
expect(await cache.listSortKeys("p")).toEqual(["B", "a", "\uff5e", "\u{1f600}"]);
|
|
169
|
+
});
|
|
170
|
+
it("drops a partition's grouped entries in one call and counts them", async () => {
|
|
171
|
+
const { cache } = await begin();
|
|
172
|
+
await cache.set({ pk: "store", sk: "a" }, 1);
|
|
173
|
+
await cache.set({ pk: "store", sk: "b" }, 2);
|
|
174
|
+
await cache.set({ pk: "other", sk: "a" }, 3);
|
|
175
|
+
expect(await cache.deletePartition("store")).toBe(2);
|
|
176
|
+
expect(await cache.listSortKeys("store")).toEqual([]);
|
|
177
|
+
expect(await cache.get({ pk: "other", sk: "a" })).toBe(3);
|
|
178
|
+
});
|
|
179
|
+
it("drops and counts a plain key sharing the partition, and leaves other partitions' plain keys alone", async () => {
|
|
180
|
+
const { cache } = await begin();
|
|
181
|
+
await cache.set("store", "a plain key sharing the partition");
|
|
182
|
+
await cache.set({ pk: "store", sk: "a" }, 1);
|
|
183
|
+
await cache.set("other", "a plain key of its own");
|
|
184
|
+
await cache.set("store:nyc-01", "a partition the first is a prefix of");
|
|
185
|
+
expect(await cache.deletePartition("store")).toBe(2);
|
|
186
|
+
expect(await cache.get("store")).toBeUndefined();
|
|
187
|
+
expect(await cache.get("other")).toBe("a plain key of its own");
|
|
188
|
+
expect(await cache.get("store:nyc-01")).toBe("a partition the first is a prefix of");
|
|
189
|
+
});
|
|
190
|
+
it("loads once for concurrent getOrSet calls, then serves what it stored", async () => {
|
|
191
|
+
const { cache } = await begin();
|
|
192
|
+
const { loader, calls } = countingLoader(() => ({ city: "New York" }));
|
|
193
|
+
const [first, second] = await Promise.all([cache.getOrSet("city", loader), cache.getOrSet("city", loader)]);
|
|
194
|
+
expect(first).toEqual({ city: "New York" });
|
|
195
|
+
expect(second).toEqual({ city: "New York" });
|
|
196
|
+
expect(calls()).toBe(1);
|
|
197
|
+
expect(await cache.getOrSet("city", loader)).toEqual({ city: "New York" });
|
|
198
|
+
expect(calls()).toBe(1);
|
|
199
|
+
expect(await cache.get("city")).toEqual({ city: "New York" });
|
|
200
|
+
});
|
|
201
|
+
it("stores a getOrSet value under the call's TTL", async () => {
|
|
202
|
+
const { clock, cache } = await begin();
|
|
203
|
+
await cache.getOrSet("short", async () => "value", { ttlSeconds: 10 });
|
|
204
|
+
clock.set(START + 9_000);
|
|
205
|
+
expect(await cache.get("short")).toBe("value");
|
|
206
|
+
clock.set(START + 10_000);
|
|
207
|
+
expect(await cache.get("short")).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
it("hands each call that shares a load or a read a parse of its own", async () => {
|
|
210
|
+
const { cache } = await begin();
|
|
211
|
+
const loader = async () => ({ list: [1] });
|
|
212
|
+
const [first, second] = await Promise.all([cache.getOrSet("shared", loader), cache.getOrSet("shared", loader)]);
|
|
213
|
+
expect(second).not.toBe(first);
|
|
214
|
+
first.list.push(2);
|
|
215
|
+
expect(second).toEqual({ list: [1] });
|
|
216
|
+
const [hit, joiner] = await Promise.all([cache.getOrSet("shared", loader), cache.getOrSet("shared", loader)]);
|
|
217
|
+
expect(joiner).not.toBe(hit);
|
|
218
|
+
expect(joiner).toEqual({ list: [1] });
|
|
219
|
+
});
|
|
220
|
+
it("leaves a loader's undefined uncached and asks the loader again next time", async () => {
|
|
221
|
+
const { cache } = await begin();
|
|
222
|
+
const { loader, calls } = countingLoader(() => undefined);
|
|
223
|
+
expect(await cache.getOrSet("not-found", loader)).toBeUndefined();
|
|
224
|
+
expect(await cache.has("not-found")).toBe(false);
|
|
225
|
+
expect(await cache.getOrSet("not-found", loader)).toBeUndefined();
|
|
226
|
+
expect(calls()).toBe(2);
|
|
227
|
+
});
|
|
228
|
+
it("caches a loader's null, the way to cache \"not found\"", async () => {
|
|
229
|
+
const { cache } = await begin();
|
|
230
|
+
const { loader, calls } = countingLoader(() => null);
|
|
231
|
+
expect(await cache.getOrSet("not-found", loader)).toBeNull();
|
|
232
|
+
expect(await cache.getOrSet("not-found", loader)).toBeNull();
|
|
233
|
+
expect(calls()).toBe(1);
|
|
234
|
+
expect(await cache.has("not-found")).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
it("answers the stored JSON on the call that filled the entry, as on every later one", async () => {
|
|
237
|
+
const { cache } = await begin();
|
|
238
|
+
const loader = async () => ({ at: new Date("2026-01-02T03:04:05.000Z"), gone: undefined, count: 1 });
|
|
239
|
+
const first = await cache.getOrSet("shape", loader);
|
|
240
|
+
const later = await cache.getOrSet("shape", loader);
|
|
241
|
+
expect(first).toEqual({ at: "2026-01-02T03:04:05.000Z", count: 1 });
|
|
242
|
+
expect(Object.keys(first)).toEqual(["at", "count"]);
|
|
243
|
+
expect(later).toEqual(first);
|
|
244
|
+
});
|
|
245
|
+
it("propagates a loader that throws, and stores nothing", async () => {
|
|
246
|
+
const { cache } = await begin();
|
|
247
|
+
await expect(cache.getOrSet("broken", async () => { throw new Error("origin down"); })).rejects.toThrow();
|
|
248
|
+
expect(await cache.has("broken")).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
it("never stores a slow fill's value over a set and a delete that landed while it loaded", async () => {
|
|
251
|
+
const { cache } = await begin();
|
|
252
|
+
const { loader, started, open } = gatedLoader({ tag: "old" });
|
|
253
|
+
const fill = cache.getOrSet("raced", loader);
|
|
254
|
+
await started;
|
|
255
|
+
await cache.set("raced", { tag: "new" });
|
|
256
|
+
// A call after the write reads it, rather than joining the load it overtook.
|
|
257
|
+
expect(await cache.getOrSet("raced", async () => ({ tag: "other" }))).toEqual({ tag: "new" });
|
|
258
|
+
await cache.delete("raced");
|
|
259
|
+
open();
|
|
260
|
+
expect(await fill).toEqual({ tag: "old" });
|
|
261
|
+
expect(await cache.get("raced")).toBeUndefined();
|
|
262
|
+
});
|
|
263
|
+
it("never stores a slow fill's value over a deletePartition that landed while it loaded", async () => {
|
|
264
|
+
const { cache } = await begin();
|
|
265
|
+
const key = { pk: "store", sk: "a" };
|
|
266
|
+
const { loader, started, open } = gatedLoader({ tag: "old" });
|
|
267
|
+
const fill = cache.getOrSet(key, loader);
|
|
268
|
+
await started;
|
|
269
|
+
expect(await cache.deletePartition("store")).toBe(0);
|
|
270
|
+
open();
|
|
271
|
+
expect(await fill).toEqual({ tag: "old" });
|
|
272
|
+
expect(await cache.get(key)).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
it("throws an invalid option to the caller before loading anything", async () => {
|
|
275
|
+
const { cache } = await begin();
|
|
276
|
+
const { loader, calls } = countingLoader(() => "value");
|
|
277
|
+
await expect(cache.set("options", 1, { ttlSeconds: 0 })).rejects.toThrow();
|
|
278
|
+
await expect(cache.getOrSet("options", loader, { ttlSeconds: 0 })).rejects.toThrow();
|
|
279
|
+
await expect(cache.getOrSet("options", loader, { ttlSeconds: 1.5 })).rejects.toThrow();
|
|
280
|
+
// The fill lease's options are checked by every cache, whether or not it holds a lease.
|
|
281
|
+
await expect(cache.getOrSet("options", loader, { leaseSeconds: 0 })).rejects.toThrow();
|
|
282
|
+
await expect(cache.getOrSet("options", loader, { waitForFillMs: 1.5 })).rejects.toThrow();
|
|
283
|
+
await expect(cache.listSortKeys("store", { limit: 0 })).rejects.toThrow();
|
|
284
|
+
expect(calls()).toBe(0);
|
|
285
|
+
expect(await cache.has("options")).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { LambderCacheStorage } from "../stores/LambderStorageBackedCache.js";
|
|
2
|
+
import { type LambderConformanceRunner } from "./LambderConformanceRunner.js";
|
|
3
|
+
export type LambderCacheStorageConformanceOptions = LambderConformanceRunner & {
|
|
4
|
+
/**
|
|
5
|
+
* A storage holding no entries, built for one case. A storage over a
|
|
6
|
+
* database empties what the previous case wrote here, since cases reuse
|
|
7
|
+
* the same addresses. It is handed no clock: a storage judges no time
|
|
8
|
+
* itself, the cache hands it the second each question is about.
|
|
9
|
+
*/
|
|
10
|
+
create: () => LambderCacheStorage | Promise<LambderCacheStorage>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Registers the cache storage rules as cases of the runner, one `it` each,
|
|
14
|
+
* against the storage `create` builds:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { describe, it, expect } from "vitest";
|
|
18
|
+
* import { lambderCacheStorageConformance } from "lambder/testing";
|
|
19
|
+
*
|
|
20
|
+
* describe("cacheEntryTable", () => {
|
|
21
|
+
* lambderCacheStorageConformance({ it, expect, create: async () => { await emptyCacheEntries(); return cacheEntryTable; } });
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare const lambderCacheStorageConformance: (options: LambderCacheStorageConformanceOptions) => void;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { normalizeCacheKey } from "../stores/LambderCacheKeys.js";
|
|
2
|
+
import { CONFORMANCE_START_MILLIS } from "./LambderConformanceRunner.js";
|
|
3
|
+
/**
|
|
4
|
+
* The second every case asks about: where every suite's clock starts, fixed
|
|
5
|
+
* and far in the future (see CONFORMANCE_START_MILLIS), so an entry counts as
|
|
6
|
+
* expired only by the storage's own answer to `nowSeconds`.
|
|
7
|
+
*/
|
|
8
|
+
const NOW = CONFORMANCE_START_MILLIS / 1000;
|
|
9
|
+
/** The address the cache would hand the storage for a key. */
|
|
10
|
+
const at = (key) => normalizeCacheKey(key);
|
|
11
|
+
const live = (json) => ({ json, expiresAt: NOW + 3600 });
|
|
12
|
+
/** An entry whose expiry is the very second asked about: no longer live, as the cache counts. */
|
|
13
|
+
const expiring = (json) => ({ json, expiresAt: NOW });
|
|
14
|
+
/** A listing in a fixed order, since a storage may answer in any. */
|
|
15
|
+
const sorted = (keys) => [...keys].sort();
|
|
16
|
+
/**
|
|
17
|
+
* Registers the cache storage rules as cases of the runner, one `it` each,
|
|
18
|
+
* against the storage `create` builds:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { describe, it, expect } from "vitest";
|
|
22
|
+
* import { lambderCacheStorageConformance } from "lambder/testing";
|
|
23
|
+
*
|
|
24
|
+
* describe("cacheEntryTable", () => {
|
|
25
|
+
* lambderCacheStorageConformance({ it, expect, create: async () => { await emptyCacheEntries(); return cacheEntryTable; } });
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export const lambderCacheStorageConformance = (options) => {
|
|
30
|
+
const { it, expect } = options;
|
|
31
|
+
const begin = async () => await options.create();
|
|
32
|
+
/** The JSON text an address holds, or null: what a case compares, whatever else the storage's entry carries. */
|
|
33
|
+
const jsonAt = async (storage, key) => (await storage.read(at(key)))?.json ?? null;
|
|
34
|
+
it("reads null at an address it holds nothing at", async () => {
|
|
35
|
+
const storage = await begin();
|
|
36
|
+
expect(await storage.read(at("order:1001"))).toBeNull();
|
|
37
|
+
expect(await storage.read(at({ pk: "store:nyc-01", sk: "hours" }))).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
it("hands back what was written: the JSON text byte for byte, and the expiry second as a number", async () => {
|
|
40
|
+
const storage = await begin();
|
|
41
|
+
// Keys out of alphabetical order, escapes and characters past ASCII:
|
|
42
|
+
// a column that parses the JSON (jsonb) hands back other text.
|
|
43
|
+
const json = JSON.stringify({ zone: "America/New_York", items: [3, 1, 2], note: "Caf\u00e9 \u2615 \"two\"\nlines" });
|
|
44
|
+
await storage.write(at("order:1001"), { json, expiresAt: NOW + 60 });
|
|
45
|
+
await storage.write(at({ pk: "store:nyc-01", sk: "hours" }), { json: "null", expiresAt: NOW + 120 });
|
|
46
|
+
const plain = await storage.read(at("order:1001"));
|
|
47
|
+
expect(plain?.json).toBe(json);
|
|
48
|
+
expect(plain?.expiresAt).toBe(NOW + 60);
|
|
49
|
+
const grouped = await storage.read(at({ pk: "store:nyc-01", sk: "hours" }));
|
|
50
|
+
expect(grouped?.json).toBe("null");
|
|
51
|
+
expect(grouped?.expiresAt).toBe(NOW + 120);
|
|
52
|
+
});
|
|
53
|
+
it("replaces the entry at an address on a second write", async () => {
|
|
54
|
+
const storage = await begin();
|
|
55
|
+
await storage.write(at("order:1001"), live("\"placed\""));
|
|
56
|
+
await storage.write(at("order:1001"), { json: "\"shipped\"", expiresAt: NOW + 7200 });
|
|
57
|
+
const read = await storage.read(at("order:1001"));
|
|
58
|
+
expect(read?.json).toBe("\"shipped\"");
|
|
59
|
+
expect(read?.expiresAt).toBe(NOW + 7200);
|
|
60
|
+
});
|
|
61
|
+
it("keeps every address apart: a plain key, the sort keys of its partition, and partitions that share a prefix or a separator", async () => {
|
|
62
|
+
const storage = await begin();
|
|
63
|
+
const keys = [
|
|
64
|
+
"store",
|
|
65
|
+
{ pk: "store", sk: "hours" },
|
|
66
|
+
{ pk: "store", sk: "hours:late" },
|
|
67
|
+
"store:nyc-01",
|
|
68
|
+
{ pk: "store:nyc-01", sk: "hours" },
|
|
69
|
+
// What a storage joining partition and sort key with "#" would file as { pk: "store", sk: "hours" }.
|
|
70
|
+
"store#hours",
|
|
71
|
+
];
|
|
72
|
+
for (const [index, key] of keys.entries())
|
|
73
|
+
await storage.write(at(key), live(String(index)));
|
|
74
|
+
for (const [index, key] of keys.entries())
|
|
75
|
+
expect(await jsonAt(storage, key)).toBe(String(index));
|
|
76
|
+
});
|
|
77
|
+
it("tells apart keys a database collation may compare as equal: by case, and by a trailing space", async () => {
|
|
78
|
+
const storage = await begin();
|
|
79
|
+
const keys = [
|
|
80
|
+
"store", "Store", "store ",
|
|
81
|
+
{ pk: "store", sk: "a" }, { pk: "store", sk: "A" }, { pk: "store", sk: "a " },
|
|
82
|
+
];
|
|
83
|
+
for (const [index, key] of keys.entries())
|
|
84
|
+
await storage.write(at(key), live(String(index)));
|
|
85
|
+
for (const [index, key] of keys.entries())
|
|
86
|
+
expect(await jsonAt(storage, key)).toBe(String(index));
|
|
87
|
+
expect(sorted(await storage.listSortKeys("store", "", NOW))).toEqual(["A", "a", "a "]);
|
|
88
|
+
});
|
|
89
|
+
it("deletes one entry, answers whether it was live at the second asked about, and leaves the rest of its partition alone", async () => {
|
|
90
|
+
const storage = await begin();
|
|
91
|
+
await storage.write(at("store"), live("\"plain\""));
|
|
92
|
+
await storage.write(at({ pk: "store", sk: "a" }), live("1"));
|
|
93
|
+
await storage.write(at({ pk: "store", sk: "b" }), expiring("2"));
|
|
94
|
+
expect(await storage.delete(at("store"), NOW)).toBe(true);
|
|
95
|
+
expect(await storage.read(at("store"))).toBeNull();
|
|
96
|
+
expect(await jsonAt(storage, { pk: "store", sk: "a" })).toBe("1");
|
|
97
|
+
// Expired at that very second: removed all the same, and not counted as live.
|
|
98
|
+
expect(await storage.delete(at({ pk: "store", sk: "b" }), NOW)).toBe(false);
|
|
99
|
+
expect(await storage.read(at({ pk: "store", sk: "b" }))).toBeNull();
|
|
100
|
+
expect(await storage.delete(at({ pk: "store", sk: "a" }), NOW)).toBe(true);
|
|
101
|
+
expect(await storage.delete(at({ pk: "store", sk: "a" }), NOW)).toBe(false);
|
|
102
|
+
expect(await storage.delete(at("absent"), NOW)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
it("judges a deleted entry live by the second it is handed, not by its own clock", async () => {
|
|
105
|
+
const storage = await begin();
|
|
106
|
+
await storage.write(at("order:1001"), expiring("1"));
|
|
107
|
+
expect(await storage.delete(at("order:1001"), NOW - 1)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it("drops every entry under a partition, the plain key's included, and counts the live ones", async () => {
|
|
110
|
+
const storage = await begin();
|
|
111
|
+
await storage.write(at("store"), live("\"plain\""));
|
|
112
|
+
await storage.write(at({ pk: "store", sk: "a" }), live("1"));
|
|
113
|
+
await storage.write(at({ pk: "store", sk: "b" }), expiring("2"));
|
|
114
|
+
await storage.write(at("store:nyc-01"), live("\"a partition 'store' is a prefix of\""));
|
|
115
|
+
await storage.write(at({ pk: "store:nyc-01", sk: "a" }), live("3"));
|
|
116
|
+
await storage.write(at({ pk: "Store", sk: "a" }), live("4"));
|
|
117
|
+
await storage.write(at({ pk: "other", sk: "a" }), live("5"));
|
|
118
|
+
expect(await storage.deletePartition("store", NOW)).toBe(2);
|
|
119
|
+
expect(await storage.read(at("store"))).toBeNull();
|
|
120
|
+
expect(await storage.read(at({ pk: "store", sk: "a" }))).toBeNull();
|
|
121
|
+
expect(await storage.read(at({ pk: "store", sk: "b" }))).toBeNull();
|
|
122
|
+
expect(await jsonAt(storage, "store:nyc-01")).toBe("\"a partition 'store' is a prefix of\"");
|
|
123
|
+
expect(await jsonAt(storage, { pk: "store:nyc-01", sk: "a" })).toBe("3");
|
|
124
|
+
expect(await jsonAt(storage, { pk: "Store", sk: "a" })).toBe("4");
|
|
125
|
+
expect(await jsonAt(storage, { pk: "other", sk: "a" })).toBe("5");
|
|
126
|
+
expect(await storage.deletePartition("store", NOW)).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
it("counts a dropped partition's live entries by the second it is handed", async () => {
|
|
129
|
+
const storage = await begin();
|
|
130
|
+
await storage.write(at({ pk: "store", sk: "a" }), live("1"));
|
|
131
|
+
await storage.write(at({ pk: "store", sk: "b" }), expiring("2"));
|
|
132
|
+
expect(await storage.deletePartition("store", NOW - 1)).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
it("lists the live sort keys under a partition, never the plain key's entry, by the second it is handed", async () => {
|
|
135
|
+
const storage = await begin();
|
|
136
|
+
await storage.write(at("store"), live("\"plain\""));
|
|
137
|
+
await storage.write(at({ pk: "store", sk: "1700:1800" }), live("1"));
|
|
138
|
+
await storage.write(at({ pk: "store", sk: "1800:1900" }), live("2"));
|
|
139
|
+
await storage.write(at({ pk: "store", sk: "0900:1000" }), expiring("3"));
|
|
140
|
+
await storage.write(at({ pk: "store:nyc-01", sk: "1700:1900" }), live("4"));
|
|
141
|
+
await storage.write(at({ pk: "other", sk: "1700:2000" }), live("5"));
|
|
142
|
+
expect(sorted(await storage.listSortKeys("store", "", NOW))).toEqual(["1700:1800", "1800:1900"]);
|
|
143
|
+
expect(sorted(await storage.listSortKeys("store", "17", NOW))).toEqual(["1700:1800"]);
|
|
144
|
+
expect(sorted(await storage.listSortKeys("store", "", NOW - 1))).toEqual(["0900:1000", "1700:1800", "1800:1900"]);
|
|
145
|
+
expect(await storage.listSortKeys("nothing-here", "", NOW)).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
it("matches a listing prefix literally, so characters a SQL pattern reads as wildcards match only themselves", async () => {
|
|
148
|
+
const storage = await begin();
|
|
149
|
+
for (const sortKey of ["a_b", "axb", "a%c", "abc", "a#b", "a~b", "a\\b"]) {
|
|
150
|
+
await storage.write(at({ pk: "store", sk: sortKey }), live("1"));
|
|
151
|
+
}
|
|
152
|
+
expect(sorted(await storage.listSortKeys("store", "a_", NOW))).toEqual(["a_b"]);
|
|
153
|
+
expect(sorted(await storage.listSortKeys("store", "a%", NOW))).toEqual(["a%c"]);
|
|
154
|
+
expect(sorted(await storage.listSortKeys("store", "a#", NOW))).toEqual(["a#b"]);
|
|
155
|
+
expect(sorted(await storage.listSortKeys("store", "a~", NOW))).toEqual(["a~b"]);
|
|
156
|
+
expect(sorted(await storage.listSortKeys("store", "a\\", NOW))).toEqual(["a\\b"]);
|
|
157
|
+
expect(await storage.listSortKeys("store", "a", NOW)).toHaveLength(7);
|
|
158
|
+
});
|
|
159
|
+
};
|
|
@@ -20,8 +20,8 @@ export type LambderSessionStoreConformanceOptions = LambderConformanceRunner & {
|
|
|
20
20
|
* ```
|
|
21
21
|
*
|
|
22
22
|
* The records a case writes are dated from the case's clock, which starts at
|
|
23
|
-
* a fixed moment and stands still unless a case moves it: a
|
|
24
|
-
* expires records by the system clock, rather than by `now`,
|
|
25
|
-
*
|
|
23
|
+
* a fixed moment far in the future and stands still unless a case moves it: a
|
|
24
|
+
* store that expires records by the system clock, rather than by `now`, never
|
|
25
|
+
* sees one expire.
|
|
26
26
|
*/
|
|
27
27
|
export declare const lambderSessionStoreConformance: (options: LambderSessionStoreConformanceOptions) => void;
|
|
@@ -13,9 +13,9 @@ import { CONFORMANCE_START_MILLIS as START, conformanceClock, } from "./LambderC
|
|
|
13
13
|
* ```
|
|
14
14
|
*
|
|
15
15
|
* The records a case writes are dated from the case's clock, which starts at
|
|
16
|
-
* a fixed moment and stands still unless a case moves it: a
|
|
17
|
-
* expires records by the system clock, rather than by `now`,
|
|
18
|
-
*
|
|
16
|
+
* a fixed moment far in the future and stands still unless a case moves it: a
|
|
17
|
+
* store that expires records by the system clock, rather than by `now`, never
|
|
18
|
+
* sees one expire.
|
|
19
19
|
*/
|
|
20
20
|
export const lambderSessionStoreConformance = (options) => {
|
|
21
21
|
const { it, expect } = options;
|
package/dist/testing.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export { lambderTestApp } from "./testing/LambderTestApp.js";
|
|
18
18
|
export type { LambderTestApp, LambderTestAppOptions, LambderTestedInstance } from "./testing/LambderTestApp.js";
|
|
19
|
-
export type { LambderTestVisitor, LambderTestVisitorOptions, LambderTestRequestInit } from "./testing/LambderTestVisitor.js";
|
|
19
|
+
export type { LambderTestVisitor, LambderTestVisitorOptions, LambderTestRequestInit, LambderTestVisitorEndpoint, LambderTestVisitorGroupCalls } from "./testing/LambderTestVisitor.js";
|
|
20
20
|
export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
21
21
|
export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
22
22
|
export { lambderSessionStoreConformance } from "./testing/lambderSessionStoreConformance.js";
|
|
@@ -27,7 +27,13 @@ export { lambderRateLimiterConformance } from "./testing/lambderRateLimiterConfo
|
|
|
27
27
|
export type { LambderRateLimiterConformanceOptions } from "./testing/lambderRateLimiterConformance.js";
|
|
28
28
|
export { lambderOneShotSecretStoreConformance } from "./testing/lambderOneShotSecretStoreConformance.js";
|
|
29
29
|
export type { LambderOneShotSecretStoreConformanceOptions } from "./testing/lambderOneShotSecretStoreConformance.js";
|
|
30
|
+
export { lambderCacheConformance } from "./testing/lambderCacheConformance.js";
|
|
31
|
+
export type { LambderCacheConformanceOptions } from "./testing/lambderCacheConformance.js";
|
|
32
|
+
export { lambderCacheStorageConformance } from "./testing/lambderCacheStorageConformance.js";
|
|
33
|
+
export type { LambderCacheStorageConformanceOptions } from "./testing/lambderCacheStorageConformance.js";
|
|
30
34
|
export type { LambderConformanceRunner, LambderConformanceIt, LambderConformanceExpect, LambderConformanceAssertion, LambderConformanceSetup, } from "./testing/LambderConformanceRunner.js";
|
|
35
|
+
export { bootLambdaPackage } from "./testing/bootLambdaPackage.js";
|
|
36
|
+
export type { LambderPackageBootOptions, LambderPackageBootCall, LambderPackageBootResult, LambderPackageBootCallResult, LambderPackageBootMeasurements, } from "./testing/bootLambdaPackage.js";
|
|
31
37
|
export { LambderMemorySessionStore } from "./stores/LambderMemorySessionStore.js";
|
|
32
38
|
export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
|
|
33
39
|
export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
|
package/dist/testing.js
CHANGED
|
@@ -23,6 +23,11 @@ export { lambderSessionStoreConformance } from "./testing/lambderSessionStoreCon
|
|
|
23
23
|
export { lambderIdempotencyStoreConformance } from "./testing/lambderIdempotencyStoreConformance.js";
|
|
24
24
|
export { lambderRateLimiterConformance } from "./testing/lambderRateLimiterConformance.js";
|
|
25
25
|
export { lambderOneShotSecretStoreConformance } from "./testing/lambderOneShotSecretStoreConformance.js";
|
|
26
|
+
export { lambderCacheConformance } from "./testing/lambderCacheConformance.js";
|
|
27
|
+
export { lambderCacheStorageConformance } from "./testing/lambderCacheStorageConformance.js";
|
|
28
|
+
// A built deployment package booted as Lambda boots it, in a fresh process,
|
|
29
|
+
// for the check between packaging and deploying.
|
|
30
|
+
export { bootLambdaPackage } from "./testing/bootLambdaPackage.js";
|
|
26
31
|
// What a test reaches for beside the test app: the stores to inspect or to
|
|
27
32
|
// hand it, a file source over fixtures, the refusal codes to assert on, and
|
|
28
33
|
// the types of what a visitor and its session hand back.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "15.0.2",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
|
|
6
6
|
"keywords": [
|
|
@@ -113,7 +113,6 @@
|
|
|
113
113
|
"@aws-sdk/client-dynamodb": "^3.868.0",
|
|
114
114
|
"@aws-sdk/client-lambda": "^3.574.0",
|
|
115
115
|
"@aws-sdk/client-s3": "^3.574.0",
|
|
116
|
-
"@aws-sdk/lib-dynamodb": "^3.574.0",
|
|
117
116
|
"@aws-sdk/s3-presigned-post": "^3.574.0",
|
|
118
117
|
"@aws-sdk/s3-request-presigner": "^3.574.0",
|
|
119
118
|
"msw": "^2.0.0",
|
|
@@ -130,9 +129,6 @@
|
|
|
130
129
|
"@aws-sdk/client-s3": {
|
|
131
130
|
"optional": true
|
|
132
131
|
},
|
|
133
|
-
"@aws-sdk/lib-dynamodb": {
|
|
134
|
-
"optional": true
|
|
135
|
-
},
|
|
136
132
|
"@aws-sdk/s3-presigned-post": {
|
|
137
133
|
"optional": true
|
|
138
134
|
},
|
|
@@ -150,7 +146,6 @@
|
|
|
150
146
|
"@aws-sdk/client-dynamodb": "^3.913.0",
|
|
151
147
|
"@aws-sdk/client-lambda": "^3.1131.0",
|
|
152
148
|
"@aws-sdk/client-s3": "^3.1127.0",
|
|
153
|
-
"@aws-sdk/lib-dynamodb": "^3.913.0",
|
|
154
149
|
"@aws-sdk/s3-presigned-post": "^3.1141.0",
|
|
155
150
|
"@aws-sdk/s3-request-presigner": "^3.1141.0",
|
|
156
151
|
"@types/js-cookie": "^3.0.6",
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounding the caller-supplied field of a tracker or scope key.
|
|
3
|
-
*
|
|
4
|
-
* The rate-limit engine and the idempotency engine each build a store key
|
|
5
|
-
* around a field only the caller controls: a custom rate-limit key or a
|
|
6
|
-
* session key, a callerIdentity or a session key. A store has a key limit of
|
|
7
|
-
* its own (a DynamoDB partition key stops at 2048 bytes) and refuses a key
|
|
8
|
-
* past it by throwing, and both engines fail open on a store throw by
|
|
9
|
-
* default: a long enough field (a 3,000-character email, a device token)
|
|
10
|
-
* would turn the rate limit or the idempotency off for that caller, in
|
|
11
|
-
* silence, while the table held the field in plain text. So the field is
|
|
12
|
-
* bounded before any store sees it, in one implementation the two engines
|
|
13
|
-
* share, so they cannot drift apart.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* The field bounded: `<kind>:<value>` while the value fits, `<kind>:h:<sha256
|
|
17
|
-
* hex>` once it does not. The digest keeps distinct callers on distinct
|
|
18
|
-
* counters and scopes, and a value that fits stays readable in the table.
|
|
19
|
-
*/
|
|
20
|
-
export declare const boundKeyField: (kind: string, value: string) => Promise<string>;
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounding the caller-supplied field of a tracker or scope key.
|
|
3
|
-
*
|
|
4
|
-
* The rate-limit engine and the idempotency engine each build a store key
|
|
5
|
-
* around a field only the caller controls: a custom rate-limit key or a
|
|
6
|
-
* session key, a callerIdentity or a session key. A store has a key limit of
|
|
7
|
-
* its own (a DynamoDB partition key stops at 2048 bytes) and refuses a key
|
|
8
|
-
* past it by throwing, and both engines fail open on a store throw by
|
|
9
|
-
* default: a long enough field (a 3,000-character email, a device token)
|
|
10
|
-
* would turn the rate limit or the idempotency off for that caller, in
|
|
11
|
-
* silence, while the table held the field in plain text. So the field is
|
|
12
|
-
* bounded before any store sees it, in one implementation the two engines
|
|
13
|
-
* share, so they cannot drift apart.
|
|
14
|
-
*/
|
|
15
|
-
import { joinKeyFields } from "./joinKeyFields.js";
|
|
16
|
-
import { sha256HexOf } from "./LambderTextDigest.js";
|
|
17
|
-
/**
|
|
18
|
-
* The ceiling, in UTF-8 bytes, on the field as it is written into the key;
|
|
19
|
-
* past it, the field is replaced by its digest. Measured escaped, since
|
|
20
|
-
* joinKeyFields doubles every separator and escape character: a field of
|
|
21
|
-
* 1,000 separators is 2,000 bytes in the key. 1024 sits well inside every
|
|
22
|
-
* store's key limit, with the store's prefix and the engine's other fields
|
|
23
|
-
* (API and policy names, a posted idempotency key of at most 200 characters)
|
|
24
|
-
* joined around it.
|
|
25
|
-
*/
|
|
26
|
-
const MAX_KEY_FIELD_BYTES = 1024;
|
|
27
|
-
/**
|
|
28
|
-
* The field bounded: `<kind>:<value>` while the value fits, `<kind>:h:<sha256
|
|
29
|
-
* hex>` once it does not. The digest keeps distinct callers on distinct
|
|
30
|
-
* counters and scopes, and a value that fits stays readable in the table.
|
|
31
|
-
*/
|
|
32
|
-
export const boundKeyField = async (kind, value) => new TextEncoder().encode(joinKeyFields(value)).length > MAX_KEY_FIELD_BYTES
|
|
33
|
-
? `${kind}:h:${await sha256HexOf(value)}`
|
|
34
|
-
: `${kind}:${value}`;
|