lambder 10.1.1 → 11.0.1
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 +81 -0
- package/README.md +1 -1
- package/dist/api/LambderApiGuards.d.ts +34 -23
- package/dist/api/LambderApiRateLimits.d.ts +34 -18
- package/dist/core/Lambder.d.ts +5 -5
- package/dist/core/LambderCreateOptions.d.ts +28 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +1 -0
- package/dist/shared/contracts/LambderCache.d.ts +5 -4
- package/dist/stores/LambderCacheFiller.d.ts +3 -2
- package/dist/stores/LambderCacheFiller.js +3 -2
- package/dist/stores/LambderCacheKeys.d.ts +15 -4
- package/dist/stores/LambderCacheKeys.js +26 -7
- package/dist/stores/LambderCacheValues.d.ts +3 -3
- package/dist/stores/LambderCacheValues.js +8 -7
- package/dist/stores/LambderDdbCache.d.ts +1 -1
- package/dist/stores/LambderDdbCache.js +1 -1
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderMemoryCache.js +4 -19
- package/dist/stores/LambderStorageBackedCache.d.ts +95 -0
- package/dist/stores/LambderStorageBackedCache.js +97 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,87 @@ sit on its first published patch, and later patches list only what they changed.
|
|
|
9
9
|
Releases up to 3.2.6 carry git tags; the ones after it were published without
|
|
10
10
|
one, so versions are not cross-linked to tag comparisons here.
|
|
11
11
|
|
|
12
|
+
## [11.0.1] - 2026-09-29
|
|
13
|
+
|
|
14
|
+
A major for an app with many APIs: what one API costs the type checker no
|
|
15
|
+
longer grows with the app's keyed guards and rate-limit policies. The same
|
|
16
|
+
declarations compile and the same ones fail, and nothing changes at runtime.
|
|
17
|
+
It is a major because the exported option types no longer take the API's
|
|
18
|
+
input.
|
|
19
|
+
|
|
20
|
+
### Changed (breaking)
|
|
21
|
+
|
|
22
|
+
- **`LambderGuardsOption`, `LambderRateLimitOption`, `LambderAllowedGuardNames`,
|
|
23
|
+
`LambderParamlessGuardNames` and `LambderAllowedPolicyNames` no longer take
|
|
24
|
+
a payload.** They name what an instance lets one kind of API declare
|
|
25
|
+
(session guards and session-keyed policies only on session APIs) and depend
|
|
26
|
+
on the instance alone. Whether an API's input carries the fields an apiInput
|
|
27
|
+
guard or an apiInput-keyed policy reads is asked separately, of the names
|
|
28
|
+
the API declared, by `LambderGuardNamesInputLacks` and
|
|
29
|
+
`LambderPolicyNamesInputLacks`.
|
|
30
|
+
- To move: drop the payload argument (`LambderGuardsOption<Guards, true>`
|
|
31
|
+
for `LambderGuardsOption<Guards, Payload, true>`), and where you relied on
|
|
32
|
+
the payload filter, ask the new types of the names you declared.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **An API's `guards` and `rateLimit` options are held to its input once the
|
|
37
|
+
input is known.** Their constraints used to read the API's own input type,
|
|
38
|
+
so for every API the compiler rebuilt the names it may declare, over every
|
|
39
|
+
guard and policy the instance holds, while that input was still being
|
|
40
|
+
inferred. The work grew with an app's APIs times its apiInput guards and
|
|
41
|
+
apiInput-keyed policies, exponentially in the worst case: 25 APIs
|
|
42
|
+
registered in one chain, each declaring one of 10 apiInput guards, took
|
|
43
|
+
12.6 million type instantiations. The constraints now depend on the
|
|
44
|
+
instance alone, and `LambderPayloadSliceCheck`, intersected onto the
|
|
45
|
+
options, refuses an apiInput guard or policy whose fields the input does
|
|
46
|
+
not carry. The same 25 APIs now take 45 thousand, about what they cost on
|
|
47
|
+
guards that read no input.
|
|
48
|
+
- **Where the error shows.** A guard or policy the input cannot carry is
|
|
49
|
+
refused on the `guards` or `rateLimit` value rather than on the name inside
|
|
50
|
+
it, and the message names the guards or policies at fault ("lambder: these
|
|
51
|
+
guards read fields this API's input does not carry"). Completion inside
|
|
52
|
+
those options offers every declared name.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **`LambderGuardNamesInputLacks` and `LambderPolicyNamesInputLacks`**: the
|
|
57
|
+
apiInput guards, and the apiInput-keyed policies, among some names whose
|
|
58
|
+
fields an input does not carry. The input is compared whole, so a union
|
|
59
|
+
input carries a slice only when every member does.
|
|
60
|
+
- **A type-cost test** (`tests/api/api-policy-type-cost.test.ts`): forty APIs
|
|
61
|
+
compiled on an instance whose ten guards and ten policies read the input,
|
|
62
|
+
and on one whose same-named ones do not, must cost about the same.
|
|
63
|
+
|
|
64
|
+
## [10.2.1] - 2026-09-29
|
|
65
|
+
|
|
66
|
+
A minor for an app that keeps its cache in its own storage. It writes a few
|
|
67
|
+
small storage methods and gets a `LambderCache` that keeps the same rules as
|
|
68
|
+
the DynamoDB cache and its memory twin, rather than rewriting those rules and
|
|
69
|
+
drifting from them. Nothing existing changes.
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- **`LambderStorageBackedCache`: a `LambderCache` over storage the app
|
|
74
|
+
supplies.** `new LambderStorageBackedCache({ storage, defaultTtlSeconds?,
|
|
75
|
+
maxValueBytes?, now? })` takes a `LambderCacheStorage`, five methods over
|
|
76
|
+
the app's own table (`read`, `write`, `delete`, `deletePartition`,
|
|
77
|
+
`listSortKeys`), and brings every rule itself: the same key checks, the
|
|
78
|
+
value stored as its JSON text and handed back as a fresh parse, the same
|
|
79
|
+
TTL and expiry second, only live entries counted by `delete` and
|
|
80
|
+
`deletePartition`, sort keys listed in the table's order whatever order the
|
|
81
|
+
storage finds them in, and `getOrSet`'s single-flight in one process, its
|
|
82
|
+
fail-open when the storage throws, and a `set`, `delete` or
|
|
83
|
+
`deletePartition` of a key winning over a fill in progress. The conformance
|
|
84
|
+
suite that drives `LambderDdbCache` and `LambderMemoryCache` drives it too.
|
|
85
|
+
It holds no fill lease across processes and no memory layer. The storage
|
|
86
|
+
receives a checked `LambderCacheAddress` (`partition`, `sortKey`, null for a
|
|
87
|
+
plain key, and `memoryKey`) and stores a `LambderCacheStoredEntry` (`json`,
|
|
88
|
+
`expiresAt` in epoch seconds). Exported with
|
|
89
|
+
`LambderStorageBackedCacheOptions`, `LambderCacheStorage`,
|
|
90
|
+
`LambderCacheStoredEntry` and `LambderCacheAddress`; docs/ddb-cache.md
|
|
91
|
+
shows a storage over a SQL table.
|
|
92
|
+
|
|
12
93
|
## [10.1.1] - 2026-09-28
|
|
13
94
|
|
|
14
95
|
A minor for an app made of parts. Each part can declare its own guards and
|
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ framework:
|
|
|
191
191
|
| --- | --- | --- |
|
|
192
192
|
| `html` / `xml` tags + `LambderTemplatingEngine` | [Templating](./docs/templating.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
|
|
193
193
|
| `createLambderI18n` | [Translations](./docs/i18n.md) | Typed translations with enforced/optional languages, component-level extension, auto language detection and on-demand language loading (isomorphic) |
|
|
194
|
-
| `LambderDdbCache` / `LambderMemoryCache` | [DynamoDB cache](./docs/ddb-cache.md) | JSON cache behind one `LambderCache` interface: DynamoDB-backed and compressed, with lease-based single-fill and grouped keys (server-only),
|
|
194
|
+
| `LambderDdbCache` / `LambderMemoryCache` / `LambderStorageBackedCache` | [DynamoDB cache](./docs/ddb-cache.md) | JSON cache behind one `LambderCache` interface: DynamoDB-backed and compressed, with lease-based single-fill and grouped keys (server-only), in memory for tests, or over an app's own storage (a SQL table, Redis) with the same rules |
|
|
195
195
|
| `LambderDdbRateLimiter` / `LambderMemoryRateLimiter` | [Rate limiter](./docs/ddb-rate-limiter.md) | Fixed-window rate limiter, atomic per window, in DynamoDB (server-only) or in memory |
|
|
196
196
|
| `LambderDdbIdempotencyStore` / `LambderMemoryIdempotencyStore` | [Idempotency store](./docs/ddb-idempotency.md) | Idempotency records with owner-checked claims, in DynamoDB (compressed replays, server-only) or in memory |
|
|
197
197
|
| `LambderMockApp` | [The mock runtime](./docs/mock.md) | The typed contract served from mock handlers over the real API pipeline, with failure injection, sessions and a call log (isomorphic) |
|
|
@@ -272,33 +272,40 @@ export type LambderGuardMetaMap<TGuards> = {
|
|
|
272
272
|
[K in keyof TGuards]: LambderGuardMeta<TGuards[K]>;
|
|
273
273
|
};
|
|
274
274
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* request of those members would be refused by the slice's parse.
|
|
275
|
+
* Guard names an API may declare: every guard the instance holds, session
|
|
276
|
+
* guards only on session APIs. Whether the API's input carries the fields an
|
|
277
|
+
* apiInput guard reads is asked of the names it declared, once that input is
|
|
278
|
+
* known, by LambderGuardNamesInputLacks.
|
|
280
279
|
*/
|
|
281
|
-
type
|
|
282
|
-
apiInput: infer R;
|
|
283
|
-
} ? ([TPayload] extends [R] ? K : never) : K;
|
|
284
|
-
/**
|
|
285
|
-
* Guard names an API may declare: apiInput-mode guards only when the API's
|
|
286
|
-
* payload carries their fields, session guards only on session APIs.
|
|
287
|
-
*/
|
|
288
|
-
export type LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession extends boolean = true> = {
|
|
280
|
+
export type LambderAllowedGuardNames<TGuards, TIncludeSession extends boolean = true> = {
|
|
289
281
|
[K in keyof TGuards]: TGuards[K] extends {
|
|
290
282
|
session: true;
|
|
291
|
-
} ? (TIncludeSession extends true ?
|
|
283
|
+
} ? (TIncludeSession extends true ? K : never) : K;
|
|
292
284
|
}[keyof TGuards] & string;
|
|
285
|
+
/**
|
|
286
|
+
* The apiInput guards among `TNames` whose fields the API's payload does not
|
|
287
|
+
* carry. addApi and addSessionApi ask this once the API's input is known, of
|
|
288
|
+
* the names its guards option holds; see LambderPayloadSliceCheck.
|
|
289
|
+
*
|
|
290
|
+
* The payload is compared whole, not member by member: a bare `TPayload
|
|
291
|
+
* extends R` would distribute over a union input, so a slice one member
|
|
292
|
+
* carries would pass on an API whose other members lack it, and every
|
|
293
|
+
* request of those members would be refused by the slice's parse.
|
|
294
|
+
*/
|
|
295
|
+
export type LambderGuardNamesInputLacks<TGuards, TNames, TPayload> = {
|
|
296
|
+
[K in TNames & keyof TGuards]: TGuards[K] extends {
|
|
297
|
+
apiInput: infer R;
|
|
298
|
+
} ? ([TPayload] extends [R] ? never : K) : never;
|
|
299
|
+
}[TNames & keyof TGuards] & string;
|
|
293
300
|
/** The allowed guard names whose handler takes no param (usable in the string/array forms). */
|
|
294
|
-
export type LambderParamlessGuardNames<TGuards,
|
|
295
|
-
[K in LambderAllowedGuardNames<TGuards,
|
|
301
|
+
export type LambderParamlessGuardNames<TGuards, TIncludeSession extends boolean> = {
|
|
302
|
+
[K in LambderAllowedGuardNames<TGuards, TIncludeSession> & keyof TGuards]: TGuards[K] extends {
|
|
296
303
|
param: undefined;
|
|
297
304
|
} ? K & string : never;
|
|
298
|
-
}[LambderAllowedGuardNames<TGuards,
|
|
305
|
+
}[LambderAllowedGuardNames<TGuards, TIncludeSession> & keyof TGuards];
|
|
299
306
|
/** The map form's full shape: every declarable guard name, each carrying its own param type. */
|
|
300
|
-
type LambderGuardsMap<TGuards,
|
|
301
|
-
readonly [K in LambderAllowedGuardNames<TGuards,
|
|
307
|
+
type LambderGuardsMap<TGuards, TIncludeSession extends boolean> = {
|
|
308
|
+
readonly [K in LambderAllowedGuardNames<TGuards, TIncludeSession> & keyof TGuards]?: TGuards[K] extends {
|
|
302
309
|
param: undefined;
|
|
303
310
|
} ? true : TGuards[K] extends {
|
|
304
311
|
param: infer P;
|
|
@@ -312,11 +319,15 @@ type LambderGuardsMap<TGuards, TPayload, TIncludeSession extends boolean> = {
|
|
|
312
319
|
*
|
|
313
320
|
* Every form is non-empty by construction, so declaring the option is always
|
|
314
321
|
* declaring a guard. See LambderNonEmptyOptionMap.
|
|
322
|
+
*
|
|
323
|
+
* It depends on the instance alone, never on the API's input, so the
|
|
324
|
+
* compiler builds it once per instance rather than once per API; see
|
|
325
|
+
* LambderPayloadSliceCheck for why, and for where the input is asked.
|
|
315
326
|
*/
|
|
316
|
-
export type LambderGuardsOption<TGuards,
|
|
317
|
-
LambderParamlessGuardNames<TGuards,
|
|
318
|
-
...LambderParamlessGuardNames<TGuards,
|
|
319
|
-
] | LambderNonEmptyOptionMap<LambderGuardsMap<TGuards,
|
|
327
|
+
export type LambderGuardsOption<TGuards, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TIncludeSession> | readonly [
|
|
328
|
+
LambderParamlessGuardNames<TGuards, TIncludeSession>,
|
|
329
|
+
...LambderParamlessGuardNames<TGuards, TIncludeSession>[]
|
|
330
|
+
] | LambderNonEmptyOptionMap<LambderGuardsMap<TGuards, TIncludeSession>>;
|
|
320
331
|
/**
|
|
321
332
|
* The typed ctx.guardData an API's handler sees: declared guards that return
|
|
322
333
|
* a value, keyed by name. Check-only (void) guards never appear.
|
|
@@ -147,22 +147,35 @@ export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderA
|
|
|
147
147
|
type LambderPolicyPerOf<TPolicy> = "per" extends keyof TPolicy ? TPolicy["per" & keyof TPolicy] : undefined;
|
|
148
148
|
/**
|
|
149
149
|
* Policy names an API may reference: session-keyed policies only on session
|
|
150
|
-
* APIs,
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* The payload is compared whole, as the guards' check does it: a union input
|
|
156
|
-
* one of whose members lacks the key's fields does not carry them, and every
|
|
157
|
-
* request of that member would be refused by the key slice's parse.
|
|
150
|
+
* APIs, and never a policy without `per`, whose key only the code that
|
|
151
|
+
* charges it knows. A policy whose type does not settle its `per` is allowed
|
|
152
|
+
* here and checked at registration. Whether the API's input carries the
|
|
153
|
+
* fields an apiInput-keyed policy reads is asked of the names it declared,
|
|
154
|
+
* once that input is known, by LambderPolicyNamesInputLacks.
|
|
158
155
|
*/
|
|
159
|
-
export type LambderAllowedPolicyNames<TPolicies,
|
|
156
|
+
export type LambderAllowedPolicyNames<TPolicies, TIncludeSession extends boolean> = {
|
|
160
157
|
[K in keyof TPolicies]: [
|
|
161
158
|
LambderPolicyPerOf<TPolicies[K]>
|
|
162
|
-
] extends [undefined] ? never : [LambderPolicyPerOf<TPolicies[K]>] extends ["session"] ? (TIncludeSession extends true ? K : never) :
|
|
163
|
-
apiInput: infer S extends z.ZodType;
|
|
164
|
-
}] ? ([TPayload] extends [z.input<S>] ? K : never) : K;
|
|
159
|
+
] extends [undefined] ? never : [LambderPolicyPerOf<TPolicies[K]>] extends ["session"] ? (TIncludeSession extends true ? K : never) : K;
|
|
165
160
|
}[keyof TPolicies] & string;
|
|
161
|
+
/**
|
|
162
|
+
* The apiInput-keyed policies among `TNames` whose key fields the API's
|
|
163
|
+
* payload does not carry. addApi and addSessionApi ask this once the API's
|
|
164
|
+
* input is known, of the names its rateLimit option holds; see
|
|
165
|
+
* LambderPayloadSliceCheck.
|
|
166
|
+
*
|
|
167
|
+
* The payload is compared whole, as LambderGuardNamesInputLacks compares it:
|
|
168
|
+
* a union input one of whose members lacks the key's fields does not carry
|
|
169
|
+
* them, and every request of that member would be refused by the key
|
|
170
|
+
* slice's parse.
|
|
171
|
+
*/
|
|
172
|
+
export type LambderPolicyNamesInputLacks<TPolicies, TNames, TPayload> = {
|
|
173
|
+
[K in TNames & keyof TPolicies]: [
|
|
174
|
+
LambderPolicyPerOf<TPolicies[K]>
|
|
175
|
+
] extends [{
|
|
176
|
+
apiInput: infer S extends z.ZodType;
|
|
177
|
+
}] ? ([TPayload] extends [z.input<S>] ? never : K) : never;
|
|
178
|
+
}[TNames & keyof TPolicies] & string;
|
|
166
179
|
/**
|
|
167
180
|
* Policy names a handler may charge itself: every policy except one keyed by
|
|
168
181
|
* a `{ apiInput?, handler }` key, which derives its key from an API's payload
|
|
@@ -222,8 +235,8 @@ type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
|
|
|
222
235
|
budget: "perPolicy";
|
|
223
236
|
} ? Pick<LambderRateLimitOverride, "refusal"> : LambderRateLimitOverride;
|
|
224
237
|
/** The map form's full shape: every referable policy name, each carrying its own override. */
|
|
225
|
-
type LambderRateLimitMap<TPolicies,
|
|
226
|
-
readonly [K in LambderAllowedPolicyNames<TPolicies,
|
|
238
|
+
type LambderRateLimitMap<TPolicies, TIncludeSession extends boolean> = {
|
|
239
|
+
readonly [K in LambderAllowedPolicyNames<TPolicies, TIncludeSession> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
|
|
227
240
|
};
|
|
228
241
|
/**
|
|
229
242
|
* The per-API `rateLimit` option: one policy name, a non-empty ordered list
|
|
@@ -234,11 +247,14 @@ type LambderRateLimitMap<TPolicies, TPayload, TIncludeSession extends boolean> =
|
|
|
234
247
|
* Every form is non-empty by construction (LambderNonEmptyOptionMap, as the
|
|
235
248
|
* guards option uses), since `rateLimit: {}`, `rateLimit: []` and
|
|
236
249
|
* `rateLimit: { policy: undefined }` would announce a limit and enforce none.
|
|
250
|
+
*
|
|
251
|
+
* It depends on the instance alone, never on the API's input, as
|
|
252
|
+
* LambderGuardsOption does; see LambderPayloadSliceCheck.
|
|
237
253
|
*/
|
|
238
|
-
export type LambderRateLimitOption<TPolicies,
|
|
239
|
-
LambderAllowedPolicyNames<TPolicies,
|
|
240
|
-
...LambderAllowedPolicyNames<TPolicies,
|
|
241
|
-
] | LambderNonEmptyOptionMap<LambderRateLimitMap<TPolicies,
|
|
254
|
+
export type LambderRateLimitOption<TPolicies, TIncludeSession extends boolean> = LambderAllowedPolicyNames<TPolicies, TIncludeSession> | readonly [
|
|
255
|
+
LambderAllowedPolicyNames<TPolicies, TIncludeSession>,
|
|
256
|
+
...LambderAllowedPolicyNames<TPolicies, TIncludeSession>[]
|
|
257
|
+
] | LambderNonEmptyOptionMap<LambderRateLimitMap<TPolicies, TIncludeSession>>;
|
|
242
258
|
/**
|
|
243
259
|
* When in a call a policy is checked.
|
|
244
260
|
*
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { LambderApiIdempotencyConfig } from "../api/LambderApiIdempotency.j
|
|
|
24
24
|
import type { LambderContractEntry, LambderJsonOf, LambderMergeContract } from "../shared/wire/LambderApiContract.js";
|
|
25
25
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
26
26
|
import type { LambderReadonlyDeep, MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
27
|
-
import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderRouteHandler, type LambderInputValidationHandler, type LambderFallbackHandler, type LambderGlobalErrorHandler, type LambderAfterRenderHook, type LambderBeforeRenderHook, type LambderFallbackHook, type LambderActionTools, type LambderCreateOptions, type LambderGivenOption, type LambderHandler, type LambderNestedOptionChecks, type LambderObjectOutputCheck, type LambderRequirableGuardsField, type LambderSessionEnabledInstance, type LambderSessionRouteHandler } from "./LambderCreateOptions.js";
|
|
27
|
+
import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderRouteHandler, type LambderInputValidationHandler, type LambderFallbackHandler, type LambderGlobalErrorHandler, type LambderAfterRenderHook, type LambderBeforeRenderHook, type LambderFallbackHook, type LambderActionTools, type LambderCreateOptions, type LambderGivenOption, type LambderHandler, type LambderNestedOptionChecks, type LambderObjectOutputCheck, type LambderPayloadSliceCheck, type LambderRequirableGuardsField, type LambderSessionEnabledInstance, type LambderSessionRouteHandler } from "./LambderCreateOptions.js";
|
|
28
28
|
/** Everything `lambder/testing` may put under a built instance: the pipeline's stores, and the source its files are read from. */
|
|
29
29
|
export type LambderInstanceBackends = LambderPipelineBackends & {
|
|
30
30
|
fileSource?: LambderFileSource;
|
|
@@ -167,7 +167,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
167
167
|
addRoute(condition: RegExp | LambderRouteConditionFn | LambderRouteMatcher, actionFn: LambderRouteHandler): this;
|
|
168
168
|
addSessionRoute<TPath extends LambderRoutePath>(condition: TPath, actionFn: ((ctx: LambderSessionRenderContext<any, TSessionData, LambderPathParamsOf<TPath>, {}, _TRateLimitPolicies>, resolver: LambderResolver) => MaybePromise<LambderResponse>) & LambderSessionEnabledInstance<_TSessionsEnabled>): this;
|
|
169
169
|
addSessionRoute(condition: RegExp | LambderRouteConditionFn | LambderRouteMatcher, actionFn: LambderSessionRouteHandler<TSessionData> & LambderSessionEnabledInstance<_TSessionsEnabled>): this;
|
|
170
|
-
addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies,
|
|
170
|
+
addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, false> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never, const TRefusalsOpt extends LambderRefusalsOption<_TRefusals> = never>(name: TName, schema: {
|
|
171
171
|
input: TInput;
|
|
172
172
|
output: TOutput;
|
|
173
173
|
} & {
|
|
@@ -189,12 +189,12 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
189
189
|
* both ends. A transport setting of this server's, not part of the API's contract.
|
|
190
190
|
*/
|
|
191
191
|
compress?: boolean | "auto";
|
|
192
|
-
} & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt> & LambderObjectOutputCheck<TOutput
|
|
192
|
+
} & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt> & LambderObjectOutputCheck<TOutput> & LambderPayloadSliceCheck<_TGuards, _TRateLimitPolicies, TGuardsOpt, TRateOpt, z.input<TInput>>,
|
|
193
193
|
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
|
|
194
194
|
handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, TSessionData, _TRateLimitPolicies> & {
|
|
195
195
|
refuse: LambderDeclaredRefuse<LambderHandlerRefusalsOf<_TRefusals, LambderApiRefusalCodes<_TRefusals, _TGuards, TRefusalsOpt, TGuardsOpt>>, _TRefusalCodesRequired>;
|
|
196
196
|
}) => MaybePromise<TAnswer>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOf<z.output<TOutput>>, "public", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt, LambderWireRefusalsOf<_TRefusals, LambderApiRefusalCodes<_TRefusals, _TGuards, TRefusalsOpt, TGuardsOpt>>>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled, _TRefusals, _TRefusalCodesRequired>;
|
|
197
|
-
addSessionApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies,
|
|
197
|
+
addSessionApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, true> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never, const TRefusalsOpt extends LambderRefusalsOption<_TRefusals> = never>(name: TName, schema: {
|
|
198
198
|
input: TInput;
|
|
199
199
|
output: TOutput;
|
|
200
200
|
} & {
|
|
@@ -216,7 +216,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
216
216
|
* both ends. A transport setting of this server's, not part of the API's contract.
|
|
217
217
|
*/
|
|
218
218
|
compress?: boolean | "auto";
|
|
219
|
-
} & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt> & LambderSessionEnabledInstance<_TSessionsEnabled> & LambderObjectOutputCheck<TOutput
|
|
219
|
+
} & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt> & LambderSessionEnabledInstance<_TSessionsEnabled> & LambderObjectOutputCheck<TOutput> & LambderPayloadSliceCheck<_TGuards, _TRateLimitPolicies, TGuardsOpt, TRateOpt, z.input<TInput>>,
|
|
220
220
|
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
|
|
221
221
|
handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, _TRateLimitPolicies> & {
|
|
222
222
|
refuse: LambderDeclaredRefuse<LambderHandlerRefusalsOf<_TRefusals, LambderApiRefusalCodes<_TRefusals, _TGuards, TRefusalsOpt, TGuardsOpt>>, _TRefusalCodesRequired>;
|
|
@@ -11,12 +11,12 @@ import type { LambderSessionDataRefreshConfig } from "../session/LambderSessionM
|
|
|
11
11
|
import type { LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
12
12
|
import type { LambderSessionCookieOptions } from "../session/LambderSessionController.js";
|
|
13
13
|
import type { LambderSessionCrypto } from "../session/LambderSessionCrypto.js";
|
|
14
|
-
import type { LambderApiGuard } from "../api/LambderApiGuards.js";
|
|
15
|
-
import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "../api/LambderApiRateLimits.js";
|
|
14
|
+
import type { LambderApiGuard, LambderGuardNamesInputLacks } from "../api/LambderApiGuards.js";
|
|
15
|
+
import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderPolicyNamesInputLacks } from "../api/LambderApiRateLimits.js";
|
|
16
16
|
import type { LambderApiIdempotencyConfig } from "../api/LambderApiIdempotency.js";
|
|
17
17
|
import { type LambderRefusalVocabulary } from "../api/LambderApiRefusals.js";
|
|
18
18
|
import type { LambderNoExtraKeys } from "../shared/util/LambderTypeUtilities.js";
|
|
19
|
-
import type { LambderJsonOf } from "../shared/wire/LambderApiContract.js";
|
|
19
|
+
import type { LambderGuardNamesIn, LambderJsonOf } from "../shared/wire/LambderApiContract.js";
|
|
20
20
|
import type { LambderRateLimitMessage } from "../shared/wire/LambderApiOptionValues.js";
|
|
21
21
|
import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
22
22
|
export type LambderRouteHandler = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
@@ -385,6 +385,31 @@ type LambderObjectOutputRefusal = {
|
|
|
385
385
|
readonly "lambder: an API answers with an object or an array. One with nothing to answer declares output: z.object({}) and returns {}.": never;
|
|
386
386
|
};
|
|
387
387
|
};
|
|
388
|
+
/**
|
|
389
|
+
* An API's guards and rate limits held to its input once the input is known.
|
|
390
|
+
* Intersected onto an API's options: a guard or a policy that reads fields
|
|
391
|
+
* the input does not carry is refused on the option that names it, the
|
|
392
|
+
* property name is the message and its value the names at fault.
|
|
393
|
+
*
|
|
394
|
+
* The options' own constraints admit every name the instance declares, and
|
|
395
|
+
* the input is asked about here instead, on purpose. A constraint that reads
|
|
396
|
+
* the API's input is rebuilt for every API while that input is still being
|
|
397
|
+
* inferred, over every name the instance declares, so the compiler's work
|
|
398
|
+
* grows with an app's APIs times its keyed guards and policies, exponentially
|
|
399
|
+
* in the worst case. Here the question is asked once per API, of the names
|
|
400
|
+
* that API declared.
|
|
401
|
+
*/
|
|
402
|
+
export type LambderPayloadSliceCheck<TGuards, TPolicies, TGuardsOpt, TRateOpt, TPayload> = LambderGuardsSliceCheck<LambderGuardNamesInputLacks<TGuards, LambderGuardNamesIn<TGuardsOpt>, TPayload>> & LambderRateLimitSliceCheck<LambderPolicyNamesInputLacks<TPolicies, LambderGuardNamesIn<TRateOpt>, TPayload>>;
|
|
403
|
+
type LambderGuardsSliceCheck<TLacking> = [TLacking] extends [never] ? unknown : {
|
|
404
|
+
guards: {
|
|
405
|
+
readonly "lambder: these guards read fields this API's input does not carry": TLacking;
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
type LambderRateLimitSliceCheck<TLacking> = [TLacking] extends [never] ? unknown : {
|
|
409
|
+
rateLimit: {
|
|
410
|
+
readonly "lambder: these rate limits are keyed by fields this API's input does not carry": TLacking;
|
|
411
|
+
};
|
|
412
|
+
};
|
|
388
413
|
/**
|
|
389
414
|
* The `guards` field of an API's options: optional by default, required once
|
|
390
415
|
* create() received the require*ApiGuards flag for that kind of API, so that
|
package/dist/index.d.ts
CHANGED
|
@@ -108,6 +108,9 @@ export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
|
108
108
|
export type { LambderDdbCacheOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
|
|
109
109
|
export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
|
|
110
110
|
export type { LambderMemoryCacheOptions } from "./stores/LambderMemoryCache.js";
|
|
111
|
+
export { LambderStorageBackedCache } from "./stores/LambderStorageBackedCache.js";
|
|
112
|
+
export type { LambderStorageBackedCacheOptions, LambderCacheStorage, LambderCacheStoredEntry, } from "./stores/LambderStorageBackedCache.js";
|
|
113
|
+
export type { LambderCacheAddress } from "./stores/LambderCacheKeys.js";
|
|
111
114
|
export { RATE_LIMIT_WINDOWS } from "./shared/contracts/LambderRateLimiter.js";
|
|
112
115
|
export type { LambderRateLimiter, LambderRateLimitWindow, LambderRateLimitPolicy, LambderRateLimitExceeded, LambderRateLimitResult, } from "./shared/contracts/LambderRateLimiter.js";
|
|
113
116
|
export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
|
|
@@ -125,12 +128,12 @@ export type { LambderDdbIdempotencyStoreOptions } from "./stores/LambderDdbIdemp
|
|
|
125
128
|
export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
|
|
126
129
|
export { lambderGuard, lambderRateLimitKey } from "./core/LambderPolicyBuilders.js";
|
|
127
130
|
export { lambderGuardBuilder } from "./api/LambderApiGuards.js";
|
|
128
|
-
export type { LambderGuardBuilder, LambderApiGuard, LambderGuardMeta, LambderGuardMetaMap, LambderGuardRunAt, LambderAllowedGuardNames, LambderParamlessGuardNames, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf, LambderGuardRefusals, LambderGuardRefusalNamesOf, } from "./api/LambderApiGuards.js";
|
|
131
|
+
export type { LambderGuardBuilder, LambderApiGuard, LambderGuardMeta, LambderGuardMetaMap, LambderGuardRunAt, LambderAllowedGuardNames, LambderGuardNamesInputLacks, LambderParamlessGuardNames, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf, LambderGuardRefusals, LambderGuardRefusalNamesOf, } from "./api/LambderApiGuards.js";
|
|
129
132
|
export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
|
|
130
133
|
export type { LambderRefusalDeclaration, LambderRefusalVocabulary, LambderApiAllowedRefusal, LambderApiAllowedRefusals, LambderEndpointRefusals, LambderRefusalsOption, LambderRefusalNamesIn, LambderHandlerRefusalsOf, LambderWireRefusalsOf, } from "./api/LambderApiRefusals.js";
|
|
131
134
|
export { lambderRateLimitKeyBuilder, rateLimitRefusal, DEFAULT_RATE_LIMIT_REFUSAL } from "./api/LambderApiRateLimits.js";
|
|
132
135
|
export type { LambderRateLimitKeyBuilder } from "./api/LambderApiRateLimits.js";
|
|
133
|
-
export type { LambderContextRateLimit, LambderContextRateLimitCheck, LambderRateLimitCheckResult, LambderChargeablePolicyNames, LambderChargeKeyArgs, LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget, LambderRateLimitChargeAt, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, LambderRateLimitOption, } from "./api/LambderApiRateLimits.js";
|
|
136
|
+
export type { LambderContextRateLimit, LambderContextRateLimitCheck, LambderRateLimitCheckResult, LambderChargeablePolicyNames, LambderChargeKeyArgs, LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget, LambderRateLimitChargeAt, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, LambderPolicyNamesInputLacks, LambderRateLimitOption, } from "./api/LambderApiRateLimits.js";
|
|
134
137
|
export type { LambderApiIdempotencyConfig } from "./api/LambderApiIdempotency.js";
|
|
135
138
|
export type { LambderGuardsOptionValue, LambderRateLimitMessage, LambderRateLimitOverride, LambderRateLimitOptionValue, LambderRefusalsOptionValue, LambderApiIdempotencyOption, } from "./shared/wire/LambderApiOptionValues.js";
|
|
136
139
|
export { apiGuardParam } from "./shared/wire/LambderApiOptionEntries.js";
|
package/dist/index.js
CHANGED
|
@@ -74,6 +74,7 @@ export { LambderSessionDataRefreshError, LambderSessionReadError } from "./sessi
|
|
|
74
74
|
export { LambderSessionNotFoundError, LambderSessionAmbiguousError } from "./session/LambderSessionController.js";
|
|
75
75
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
76
76
|
export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
|
|
77
|
+
export { LambderStorageBackedCache } from "./stores/LambderStorageBackedCache.js";
|
|
77
78
|
// Fixed-window rate limiting: the shared vocabulary, the DynamoDB limiter and the in-memory one
|
|
78
79
|
export { RATE_LIMIT_WINDOWS } from "./shared/contracts/LambderRateLimiter.js";
|
|
79
80
|
export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
|
|
@@ -29,10 +29,11 @@ export interface LambderCacheListOptions {
|
|
|
29
29
|
limit?: number;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* What an app asks of a cache. LambderDdbCache and
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* same over
|
|
32
|
+
* What an app asks of a cache. LambderDdbCache, LambderMemoryCache and
|
|
33
|
+
* LambderStorageBackedCache (over an app's own storage) implement it with the
|
|
34
|
+
* same rules (the same key limits, the same JSON round trip, the same
|
|
35
|
+
* expiry), so code written against this interface behaves the same over any
|
|
36
|
+
* of them.
|
|
36
37
|
*
|
|
37
38
|
* Values are JSON: what `set` is handed is stored as its JSON text and `get`
|
|
38
39
|
* hands back a parse of it, never the object that was stored.
|
|
@@ -26,8 +26,9 @@ export type LambderCacheLoad<T> = (store: (value: T) => Promise<T>) => Promise<T
|
|
|
26
26
|
* write starts a load of its own instead of joining the old one. The old
|
|
27
27
|
* fill's callers get its value uncached.
|
|
28
28
|
*
|
|
29
|
-
* LambderDdbCache and
|
|
30
|
-
* and hold one filler, so
|
|
29
|
+
* LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache each
|
|
30
|
+
* supply only their read-then-fill and hold one filler, so they cannot drift
|
|
31
|
+
* on any of the above.
|
|
31
32
|
*/
|
|
32
33
|
export declare class LambderCacheFiller {
|
|
33
34
|
private readonly inFlight;
|
|
@@ -20,8 +20,9 @@ import { asStoredJson } from "./LambderCacheValues.js";
|
|
|
20
20
|
* write starts a load of its own instead of joining the old one. The old
|
|
21
21
|
* fill's callers get its value uncached.
|
|
22
22
|
*
|
|
23
|
-
* LambderDdbCache and
|
|
24
|
-
* and hold one filler, so
|
|
23
|
+
* LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache each
|
|
24
|
+
* supply only their read-then-fill and hold one filler, so they cannot drift
|
|
25
|
+
* on any of the above.
|
|
25
26
|
*/
|
|
26
27
|
export class LambderCacheFiller {
|
|
27
28
|
inFlight = new Map();
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { LambderCacheKey } from "../shared/contracts/LambderCache.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* One entry's address, normalized: `sortKey` is null for a plain string key.
|
|
4
|
+
* A sort key is never empty (a blank one is refused), so a storage whose
|
|
5
|
+
* column cannot hold null can keep a plain key's entry under "".
|
|
6
|
+
*/
|
|
3
7
|
export interface LambderCacheAddress {
|
|
4
8
|
partition: string;
|
|
5
9
|
sortKey: string | null;
|
|
6
|
-
/**
|
|
10
|
+
/** One string that tells every address apart: the key of an in-memory layer, an in-flight map, or a storage keyed by one string. */
|
|
7
11
|
memoryKey: string;
|
|
8
12
|
}
|
|
9
13
|
/**
|
|
@@ -13,11 +17,18 @@ export interface LambderCacheAddress {
|
|
|
13
17
|
* `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
|
|
14
18
|
* per-character, so a prefix of the raw key stays a prefix of the encoded
|
|
15
19
|
* one; only the sort ORDER of keys that contain `#` or `~` shifts, since both
|
|
16
|
-
* encode into the `~` range. The
|
|
17
|
-
* listSortKeys answers in the same order over
|
|
20
|
+
* encode into the `~` range. The other caches sort by the same encoding (see
|
|
21
|
+
* compareCacheSortKeys), so listSortKeys answers in the same order over any.
|
|
18
22
|
*/
|
|
19
23
|
export declare const encodeCacheSortKey: (value: string) => string;
|
|
20
24
|
export declare const decodeCacheSortKey: (value: string) => string;
|
|
25
|
+
/**
|
|
26
|
+
* Orders two sort keys as LambderDdbCache lists them, for the caches that
|
|
27
|
+
* sort a listing themselves: by the whole item sort key the table ranges
|
|
28
|
+
* over, which carries a "#" after the encoded key. So "New York City" sorts
|
|
29
|
+
* before "New York" (" " is below "#"), over every cache alike.
|
|
30
|
+
*/
|
|
31
|
+
export declare const compareCacheSortKeys: (first: string, second: string) => number;
|
|
21
32
|
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
22
33
|
export declare const cacheMemoryKeyOf: (partition: string, sortKey: string | null) => string;
|
|
23
34
|
/** A partition as the caches accept it, or a throw naming what is wrong with it. */
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* How a cache key is checked and
|
|
2
|
+
* How a cache key is checked, spelled and ordered, for every cache here.
|
|
3
3
|
*
|
|
4
|
-
* LambderDdbCache and
|
|
5
|
-
* them refuses has to be refused by the
|
|
6
|
-
* memory cache must not accept a key production then
|
|
7
|
-
* are written once, here, and
|
|
4
|
+
* LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache take the
|
|
5
|
+
* same keys, and a key one of them refuses has to be refused by the others: a
|
|
6
|
+
* test that runs over the memory cache must not accept a key production then
|
|
7
|
+
* throws on. So the rules are written once, here, and every cache normalizes
|
|
8
|
+
* through them.
|
|
8
9
|
*/
|
|
9
10
|
/** Longest partition accepted, in UTF-8 bytes. */
|
|
10
11
|
const MAX_PARTITION_BYTES = 8 * 1024;
|
|
@@ -18,11 +19,29 @@ const utf8Bytes = (value) => new TextEncoder().encode(value).length;
|
|
|
18
19
|
* `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
|
|
19
20
|
* per-character, so a prefix of the raw key stays a prefix of the encoded
|
|
20
21
|
* one; only the sort ORDER of keys that contain `#` or `~` shifts, since both
|
|
21
|
-
* encode into the `~` range. The
|
|
22
|
-
* listSortKeys answers in the same order over
|
|
22
|
+
* encode into the `~` range. The other caches sort by the same encoding (see
|
|
23
|
+
* compareCacheSortKeys), so listSortKeys answers in the same order over any.
|
|
23
24
|
*/
|
|
24
25
|
export const encodeCacheSortKey = (value) => value.replace(/~/g, "~0").replace(/#/g, "~1");
|
|
25
26
|
export const decodeCacheSortKey = (value) => value.replace(/~([01])/g, (_match, code) => code === "0" ? "~" : "#");
|
|
27
|
+
/** DynamoDB orders string range keys by their UTF-8 bytes, which is not JavaScript's UTF-16 order for every character. */
|
|
28
|
+
const compareUtf8 = (first, second) => {
|
|
29
|
+
const a = new TextEncoder().encode(first);
|
|
30
|
+
const b = new TextEncoder().encode(second);
|
|
31
|
+
const length = Math.min(a.length, b.length);
|
|
32
|
+
for (let index = 0; index < length; index += 1) {
|
|
33
|
+
if (a[index] !== b[index])
|
|
34
|
+
return a[index] - b[index];
|
|
35
|
+
}
|
|
36
|
+
return a.length - b.length;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Orders two sort keys as LambderDdbCache lists them, for the caches that
|
|
40
|
+
* sort a listing themselves: by the whole item sort key the table ranges
|
|
41
|
+
* over, which carries a "#" after the encoded key. So "New York City" sorts
|
|
42
|
+
* before "New York" (" " is below "#"), over every cache alike.
|
|
43
|
+
*/
|
|
44
|
+
export const compareCacheSortKeys = (first, second) => compareUtf8(`${encodeCacheSortKey(first)}#`, `${encodeCacheSortKey(second)}#`);
|
|
26
45
|
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
27
46
|
export const cacheMemoryKeyOf = (partition, sortKey) => `${partition.length}:${partition}#${sortKey ?? ""}`;
|
|
28
47
|
/** A partition as the caches accept it, or a throw naming what is wrong with it. */
|
|
@@ -34,9 +34,9 @@ export declare const resolveCacheTtlSeconds: (ttlSeconds: number | undefined, de
|
|
|
34
34
|
* cache failing: every call would log, hand the loader's value back
|
|
35
35
|
* uncached, and caching would be off without the caller ever seeing why.
|
|
36
36
|
*
|
|
37
|
-
* The lease options are LambderDdbCache's. The
|
|
38
|
-
* but
|
|
39
|
-
* is refused by
|
|
37
|
+
* The lease options are LambderDdbCache's. The other caches hold no lease
|
|
38
|
+
* but check them all the same, so an options object that the table refuses
|
|
39
|
+
* is refused by every cache.
|
|
40
40
|
*/
|
|
41
41
|
export declare const resolveGetOrSetOptions: (options: {
|
|
42
42
|
ttlSeconds?: number;
|
|
@@ -3,10 +3,11 @@ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
|
3
3
|
* What a cache value has to be, and how a cache call's options are read, for
|
|
4
4
|
* every cache here.
|
|
5
5
|
*
|
|
6
|
-
* LambderDdbCache and
|
|
7
|
-
* options for the reason they refuse the same keys
|
|
8
|
-
* test that runs over the memory cache must not
|
|
9
|
-
* throws on. So the rules are written once, here,
|
|
6
|
+
* LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache refuse the
|
|
7
|
+
* same values and the same options for the reason they refuse the same keys
|
|
8
|
+
* (see LambderCacheKeys): a test that runs over the memory cache must not
|
|
9
|
+
* accept what production then throws on. So the rules are written once, here,
|
|
10
|
+
* and every cache calls them.
|
|
10
11
|
*/
|
|
11
12
|
/** How long an entry lives when neither the call nor the cache names a TTL: one year. */
|
|
12
13
|
export const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
@@ -53,9 +54,9 @@ export const resolveCacheTtlSeconds = (ttlSeconds, defaultTtlSeconds) => assertP
|
|
|
53
54
|
* cache failing: every call would log, hand the loader's value back
|
|
54
55
|
* uncached, and caching would be off without the caller ever seeing why.
|
|
55
56
|
*
|
|
56
|
-
* The lease options are LambderDdbCache's. The
|
|
57
|
-
* but
|
|
58
|
-
* is refused by
|
|
57
|
+
* The lease options are LambderDdbCache's. The other caches hold no lease
|
|
58
|
+
* but check them all the same, so an options object that the table refuses
|
|
59
|
+
* is refused by every cache.
|
|
59
60
|
*/
|
|
60
61
|
export const resolveGetOrSetOptions = (options, defaultTtlSeconds) => {
|
|
61
62
|
const leaseSeconds = assertPositiveInteger(options.leaseSeconds ?? DEFAULT_LEASE_SECONDS, "leaseSeconds");
|
|
@@ -80,7 +80,7 @@ export declare class LambderDdbCache implements LambderCache {
|
|
|
80
80
|
private readonly compression;
|
|
81
81
|
private readonly maxValueBytes;
|
|
82
82
|
private readonly memory;
|
|
83
|
-
/** getOrSet's single-flight and fail-open, shared with
|
|
83
|
+
/** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
|
|
84
84
|
private readonly filler;
|
|
85
85
|
private readonly now;
|
|
86
86
|
/** This instance's own writes, as its reads and its memory layer need to know them (see LocalWriteLedger). */
|
|
@@ -202,7 +202,7 @@ export class LambderDdbCache {
|
|
|
202
202
|
compression;
|
|
203
203
|
maxValueBytes;
|
|
204
204
|
memory;
|
|
205
|
-
/** getOrSet's single-flight and fail-open, shared with
|
|
205
|
+
/** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
|
|
206
206
|
filler;
|
|
207
207
|
now;
|
|
208
208
|
/** This instance's own writes, as its reads and its memory layer need to know them (see LocalWriteLedger). */
|
|
@@ -16,7 +16,7 @@ export interface LambderMemoryCacheOptions {
|
|
|
16
16
|
* refuses, stores a value's JSON text and hands back a fresh parse of it,
|
|
17
17
|
* expires entries on the same TTL, and lists sort keys in the same order.
|
|
18
18
|
*
|
|
19
|
-
* getOrSet runs through the LambderCacheFiller
|
|
19
|
+
* getOrSet runs through the LambderCacheFiller every cache holds: concurrent
|
|
20
20
|
* calls for one key share a load, every call (the filling one included)
|
|
21
21
|
* answers the stored JSON parsed, a loader's undefined comes back uncached,
|
|
22
22
|
* and a set, delete or deletePartition of the key while the loader runs
|
|
@@ -29,7 +29,7 @@ export interface LambderMemoryCacheOptions {
|
|
|
29
29
|
*/
|
|
30
30
|
export declare class LambderMemoryCache implements LambderCache {
|
|
31
31
|
private readonly entries;
|
|
32
|
-
/** getOrSet's single-flight and fail-open, shared with
|
|
32
|
+
/** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
|
|
33
33
|
private readonly filler;
|
|
34
34
|
private readonly defaultTtlSeconds;
|
|
35
35
|
private readonly maxValueBytes;
|
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
|
|
2
2
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
3
|
-
import { cacheMemoryKeyOf,
|
|
3
|
+
import { cacheMemoryKeyOf, compareCacheSortKeys, normalizeCacheKey, normalizeCachePartition } from "./LambderCacheKeys.js";
|
|
4
4
|
import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
|
|
5
5
|
import { LambderCacheFiller } from "./LambderCacheFiller.js";
|
|
6
|
-
/** DynamoDB orders string range keys by their UTF-8 bytes, which is not JavaScript's UTF-16 order for every character. */
|
|
7
|
-
const compareUtf8 = (first, second) => {
|
|
8
|
-
const a = new TextEncoder().encode(first);
|
|
9
|
-
const b = new TextEncoder().encode(second);
|
|
10
|
-
const length = Math.min(a.length, b.length);
|
|
11
|
-
for (let index = 0; index < length; index += 1) {
|
|
12
|
-
if (a[index] !== b[index])
|
|
13
|
-
return a[index] - b[index];
|
|
14
|
-
}
|
|
15
|
-
return a.length - b.length;
|
|
16
|
-
};
|
|
17
6
|
/**
|
|
18
7
|
* LambderDdbCache's twin, held in memory: the same LambderCache interface and
|
|
19
8
|
* the same rules, so code written against the interface can be tested
|
|
@@ -21,7 +10,7 @@ const compareUtf8 = (first, second) => {
|
|
|
21
10
|
* refuses, stores a value's JSON text and hands back a fresh parse of it,
|
|
22
11
|
* expires entries on the same TTL, and lists sort keys in the same order.
|
|
23
12
|
*
|
|
24
|
-
* getOrSet runs through the LambderCacheFiller
|
|
13
|
+
* getOrSet runs through the LambderCacheFiller every cache holds: concurrent
|
|
25
14
|
* calls for one key share a load, every call (the filling one included)
|
|
26
15
|
* answers the stored JSON parsed, a loader's undefined comes back uncached,
|
|
27
16
|
* and a set, delete or deletePartition of the key while the loader runs
|
|
@@ -34,7 +23,7 @@ const compareUtf8 = (first, second) => {
|
|
|
34
23
|
*/
|
|
35
24
|
export class LambderMemoryCache {
|
|
36
25
|
entries;
|
|
37
|
-
/** getOrSet's single-flight and fail-open, shared with
|
|
26
|
+
/** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
|
|
38
27
|
filler = new LambderCacheFiller("Memory cache failed open");
|
|
39
28
|
defaultTtlSeconds;
|
|
40
29
|
maxValueBytes;
|
|
@@ -83,11 +72,7 @@ export class LambderMemoryCache {
|
|
|
83
72
|
const limit = options.limit === undefined ? undefined : assertPositiveInteger(options.limit, "limit");
|
|
84
73
|
const sortKeys = this.entries.values()
|
|
85
74
|
.flatMap(({ address }) => address.partition === normalized && address.sortKey !== null && address.sortKey.startsWith(prefix) ? [address.sortKey] : [])
|
|
86
|
-
|
|
87
|
-
// item sort key, which carries a "#" after the encoded key. So
|
|
88
|
-
// "New York City" sorts before "New York" there (" " is below
|
|
89
|
-
// "#"), and here too.
|
|
90
|
-
.sort((first, second) => compareUtf8(`${encodeCacheSortKey(first)}#`, `${encodeCacheSortKey(second)}#`));
|
|
75
|
+
.sort(compareCacheSortKeys);
|
|
91
76
|
return limit === undefined ? sortKeys : sortKeys.slice(0, limit);
|
|
92
77
|
}
|
|
93
78
|
async getOrSet(key, loader, options = {}) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
|
|
2
|
+
import { type LambderCacheAddress } from "./LambderCacheKeys.js";
|
|
3
|
+
/** One entry as a storage keeps it: the value's JSON text and the second it stops being live. */
|
|
4
|
+
export interface LambderCacheStoredEntry {
|
|
5
|
+
json: string;
|
|
6
|
+
/** Epoch seconds: the entry is live while this is greater than the current second, so it expires at this second itself. */
|
|
7
|
+
expiresAt: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where a LambderStorageBackedCache keeps its entries: a SQL table, a Redis
|
|
11
|
+
* database, anything that can hold a JSON text and an expiry under an
|
|
12
|
+
* address. Only reading and writing are asked of it; the key checks, the
|
|
13
|
+
* JSON round trip, the TTL, the listing order and getOrSet's single-flight
|
|
14
|
+
* are the cache's, so no storage can drift from the rules the other caches
|
|
15
|
+
* keep.
|
|
16
|
+
*
|
|
17
|
+
* An entry is live at `nowSeconds` while its `expiresAt` is greater than
|
|
18
|
+
* `nowSeconds`. The cache hands `nowSeconds` to the methods that answer
|
|
19
|
+
* about live entries, so a storage can filter in its own query. It never
|
|
20
|
+
* removes an expired entry it is not asked to, so a storage purges them on
|
|
21
|
+
* its own schedule (a periodic delete, a native TTL) or grows without bound.
|
|
22
|
+
*
|
|
23
|
+
* A plain string key and the `{ pk, sk }` keys of one partition share that
|
|
24
|
+
* partition: `address.sortKey` is null for the plain one.
|
|
25
|
+
*/
|
|
26
|
+
export interface LambderCacheStorage {
|
|
27
|
+
/** The entry at this address, expired or not: the cache decides what is live. Null when there is none. */
|
|
28
|
+
read(address: LambderCacheAddress): Promise<LambderCacheStoredEntry | null>;
|
|
29
|
+
/** Creates the entry at this address, or replaces the one there. */
|
|
30
|
+
write(address: LambderCacheAddress, entry: LambderCacheStoredEntry): Promise<void>;
|
|
31
|
+
/** Removes the entry at this address, live or not; true when it was live at `nowSeconds`. */
|
|
32
|
+
delete(address: LambderCacheAddress, nowSeconds: number): Promise<boolean>;
|
|
33
|
+
/** Removes every entry under the partition, plain key and sort keys alike, and answers how many of them were live at `nowSeconds`. */
|
|
34
|
+
deletePartition(partition: string, nowSeconds: number): Promise<number>;
|
|
35
|
+
/**
|
|
36
|
+
* Every sort key under the partition that starts with `prefix` and is
|
|
37
|
+
* live at `nowSeconds`, in any order: the cache sorts the list and cuts
|
|
38
|
+
* it to the caller's limit. A plain key's entry has no sort key and is
|
|
39
|
+
* never listed.
|
|
40
|
+
*/
|
|
41
|
+
listSortKeys(partition: string, prefix: string, nowSeconds: number): Promise<string[]>;
|
|
42
|
+
}
|
|
43
|
+
export interface LambderStorageBackedCacheOptions {
|
|
44
|
+
/** Where the entries live. */
|
|
45
|
+
storage: LambderCacheStorage;
|
|
46
|
+
/** Default: one year, as the other caches'. */
|
|
47
|
+
defaultTtlSeconds?: number;
|
|
48
|
+
/** Largest value accepted, in UTF-8 bytes of its JSON. Default: 32 MiB, as the other caches'. */
|
|
49
|
+
maxValueBytes?: number;
|
|
50
|
+
/** The clock entries expire against, in epoch milliseconds, so a test can cross a TTL without waiting. */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A LambderCache over storage the app supplies (see LambderCacheStorage),
|
|
55
|
+
* with every rule LambderMemoryCache keeps: it refuses the keys and values
|
|
56
|
+
* the other caches refuse, stores a value's JSON text and hands back a fresh
|
|
57
|
+
* parse of it, expires entries on the same TTL, counts only live entries in
|
|
58
|
+
* what delete and deletePartition answer, and lists sort keys in the table's
|
|
59
|
+
* order whatever order the storage finds them in.
|
|
60
|
+
*
|
|
61
|
+
* getOrSet runs through the LambderCacheFiller every cache holds: concurrent
|
|
62
|
+
* calls for one key in this process share a load, every call answers the
|
|
63
|
+
* stored JSON parsed, a loader's undefined comes back uncached, a set,
|
|
64
|
+
* delete or deletePartition of the key while the loader runs keeps the fill
|
|
65
|
+
* from storing over it, and a storage that fails (a read, a write) hands the
|
|
66
|
+
* loader's value back uncached rather than failing the caller. A fill whose
|
|
67
|
+
* write is already on its way to the storage when a write of the same key
|
|
68
|
+
* arrives races it there, as two overlapping sets do.
|
|
69
|
+
*
|
|
70
|
+
* It holds no fill lease across processes: two processes missing one key
|
|
71
|
+
* each run the loader, and the last write stands. It keeps nothing in
|
|
72
|
+
* memory either, so every read asks the storage.
|
|
73
|
+
*/
|
|
74
|
+
export declare class LambderStorageBackedCache implements LambderCache {
|
|
75
|
+
private readonly storage;
|
|
76
|
+
/** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
|
|
77
|
+
private readonly filler;
|
|
78
|
+
private readonly defaultTtlSeconds;
|
|
79
|
+
private readonly maxValueBytes;
|
|
80
|
+
private readonly now;
|
|
81
|
+
constructor(options: LambderStorageBackedCacheOptions);
|
|
82
|
+
get<T>(key: LambderCacheKey): Promise<T | undefined>;
|
|
83
|
+
has(key: LambderCacheKey): Promise<boolean>;
|
|
84
|
+
set<T>(key: LambderCacheKey, value: T, options?: LambderCacheSetOptions): Promise<void>;
|
|
85
|
+
delete(key: LambderCacheKey): Promise<boolean>;
|
|
86
|
+
deletePartition(partition: string): Promise<number>;
|
|
87
|
+
listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
|
|
88
|
+
getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheSetOptions): Promise<T>;
|
|
89
|
+
/** Whole seconds, as entries expire in every cache here, so a TTL ends on the same second as the memory cache's. */
|
|
90
|
+
private nowSeconds;
|
|
91
|
+
/** The entry at the address while it is live; the storage may still hold one past its expiry. */
|
|
92
|
+
private readLive;
|
|
93
|
+
/** Stores the value and hands back what was stored, the parse of its JSON. */
|
|
94
|
+
private setByAddress;
|
|
95
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
2
|
+
import { cacheMemoryKeyOf, compareCacheSortKeys, normalizeCacheKey, normalizeCachePartition } from "./LambderCacheKeys.js";
|
|
3
|
+
import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
|
|
4
|
+
import { LambderCacheFiller } from "./LambderCacheFiller.js";
|
|
5
|
+
/**
|
|
6
|
+
* A LambderCache over storage the app supplies (see LambderCacheStorage),
|
|
7
|
+
* with every rule LambderMemoryCache keeps: it refuses the keys and values
|
|
8
|
+
* the other caches refuse, stores a value's JSON text and hands back a fresh
|
|
9
|
+
* parse of it, expires entries on the same TTL, counts only live entries in
|
|
10
|
+
* what delete and deletePartition answer, and lists sort keys in the table's
|
|
11
|
+
* order whatever order the storage finds them in.
|
|
12
|
+
*
|
|
13
|
+
* getOrSet runs through the LambderCacheFiller every cache holds: concurrent
|
|
14
|
+
* calls for one key in this process share a load, every call answers the
|
|
15
|
+
* stored JSON parsed, a loader's undefined comes back uncached, a set,
|
|
16
|
+
* delete or deletePartition of the key while the loader runs keeps the fill
|
|
17
|
+
* from storing over it, and a storage that fails (a read, a write) hands the
|
|
18
|
+
* loader's value back uncached rather than failing the caller. A fill whose
|
|
19
|
+
* write is already on its way to the storage when a write of the same key
|
|
20
|
+
* arrives races it there, as two overlapping sets do.
|
|
21
|
+
*
|
|
22
|
+
* It holds no fill lease across processes: two processes missing one key
|
|
23
|
+
* each run the loader, and the last write stands. It keeps nothing in
|
|
24
|
+
* memory either, so every read asks the storage.
|
|
25
|
+
*/
|
|
26
|
+
export class LambderStorageBackedCache {
|
|
27
|
+
storage;
|
|
28
|
+
/** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
|
|
29
|
+
filler = new LambderCacheFiller("Storage-backed cache failed open");
|
|
30
|
+
defaultTtlSeconds;
|
|
31
|
+
maxValueBytes;
|
|
32
|
+
now;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
// Here rather than at first use, where getOrSet would fail open over
|
|
35
|
+
// it on every call and caching would be off without a caller seeing why.
|
|
36
|
+
if (!options.storage)
|
|
37
|
+
throw new Error("storage is required");
|
|
38
|
+
this.storage = options.storage;
|
|
39
|
+
this.now = options.now ?? (() => Date.now());
|
|
40
|
+
this.defaultTtlSeconds = assertPositiveInteger(options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS, "defaultTtlSeconds");
|
|
41
|
+
this.maxValueBytes = assertPositiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
|
|
42
|
+
}
|
|
43
|
+
async get(key) {
|
|
44
|
+
const entry = await this.readLive(normalizeCacheKey(key));
|
|
45
|
+
return entry ? JSON.parse(entry.json) : undefined;
|
|
46
|
+
}
|
|
47
|
+
async has(key) {
|
|
48
|
+
return await this.readLive(normalizeCacheKey(key)) !== null;
|
|
49
|
+
}
|
|
50
|
+
async set(key, value, options = {}) {
|
|
51
|
+
const address = normalizeCacheKey(key);
|
|
52
|
+
const ttlSeconds = resolveCacheTtlSeconds(options.ttlSeconds, this.defaultTtlSeconds);
|
|
53
|
+
this.filler.supersedeFill(address.memoryKey);
|
|
54
|
+
await this.setByAddress(address, value, ttlSeconds);
|
|
55
|
+
}
|
|
56
|
+
async delete(key) {
|
|
57
|
+
const address = normalizeCacheKey(key);
|
|
58
|
+
this.filler.supersedeFill(address.memoryKey);
|
|
59
|
+
return await this.storage.delete(address, this.nowSeconds());
|
|
60
|
+
}
|
|
61
|
+
async deletePartition(partition) {
|
|
62
|
+
const normalized = normalizeCachePartition(partition);
|
|
63
|
+
this.filler.supersedeFillsWithPrefix(cacheMemoryKeyOf(normalized, ""));
|
|
64
|
+
return await this.storage.deletePartition(normalized, this.nowSeconds());
|
|
65
|
+
}
|
|
66
|
+
async listSortKeys(partition, options = {}) {
|
|
67
|
+
const normalized = normalizeCachePartition(partition);
|
|
68
|
+
const prefix = options.prefix ?? "";
|
|
69
|
+
const limit = options.limit === undefined ? undefined : assertPositiveInteger(options.limit, "limit");
|
|
70
|
+
// A copy, since the storage may hand back a list it holds on to.
|
|
71
|
+
const sortKeys = [...await this.storage.listSortKeys(normalized, prefix, this.nowSeconds())].sort(compareCacheSortKeys);
|
|
72
|
+
return limit === undefined ? sortKeys : sortKeys.slice(0, limit);
|
|
73
|
+
}
|
|
74
|
+
async getOrSet(key, loader, options = {}) {
|
|
75
|
+
const address = normalizeCacheKey(key);
|
|
76
|
+
const { ttlSeconds } = resolveGetOrSetOptions(options, this.defaultTtlSeconds);
|
|
77
|
+
return this.filler.getOrSet(address.memoryKey, loader, async (load) => {
|
|
78
|
+
const existing = await this.readLive(address);
|
|
79
|
+
return existing ? JSON.parse(existing.json) : await load(async (value) => await this.setByAddress(address, value, ttlSeconds));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Whole seconds, as entries expire in every cache here, so a TTL ends on the same second as the memory cache's. */
|
|
83
|
+
nowSeconds() {
|
|
84
|
+
return Math.floor(this.now() / 1000);
|
|
85
|
+
}
|
|
86
|
+
/** The entry at the address while it is live; the storage may still hold one past its expiry. */
|
|
87
|
+
async readLive(address) {
|
|
88
|
+
const entry = await this.storage.read(address);
|
|
89
|
+
return entry && entry.expiresAt > this.nowSeconds() ? entry : null;
|
|
90
|
+
}
|
|
91
|
+
/** Stores the value and hands back what was stored, the parse of its JSON. */
|
|
92
|
+
async setByAddress(address, value, ttlSeconds) {
|
|
93
|
+
const { json } = serializeCacheValue(value, this.maxValueBytes);
|
|
94
|
+
await this.storage.write(address, { json, expiresAt: this.nowSeconds() + ttlSeconds });
|
|
95
|
+
return JSON.parse(json);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.1",
|
|
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": [
|