lambder 4.7.3 → 4.9.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/Readme.md +54 -2
- package/dist/core/Lambder.d.ts +33 -14
- package/dist/core/Lambder.js +13 -5
- package/dist/index.d.ts +1 -1
- package/dist/policies/LambderApiGuards.d.ts +30 -6
- package/dist/policies/LambderApiGuards.js +12 -1
- package/dist/shared/LambderApiContract.d.ts +13 -6
- package/dist/stores/LambderDdbCache.d.ts +65 -5
- package/dist/stores/LambderDdbCache.js +209 -89
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
|
|
4
4
|
|
|
5
|
+
**New in 4.9:**
|
|
6
|
+
|
|
7
|
+
- **Mandatory authorization on public APIs**: `requirePublicApiGuards: true` at creation makes `guards` a required field of every `addApi`, the same way `requireSessionApiGuards` does for session APIs, at the type level and at registration. Public APIs are open by default and that stays the default; what turning it on buys is that a public endpoint's openness becomes a written decision rather than an omission. The ones anybody may call declare a named no-op guard carrying the reason (`guards: { open: "Static strings already in the bundle." }`), the ones that authorize their caller some other way (a signature, a device secret, a one-shot token) name where that happens, and one grep over the guard names then lists every public door and why it is open. The two flags are independent, so an app can require either or both.
|
|
8
|
+
- **An empty guards option is refused**: `guards: {}` and `guards: []` were inhabited by the option type and passed the require\*ApiGuards field check while normalizing to zero entries, so a declaration that authorized nothing satisfied a requirement that exists to make authorization explicit. Both are now compile errors (every form of the option is non-empty by construction) and a registration error for a plain-JS caller, whichever flag is on or off. Requiring the chosen key also rejects `guards: { theGuard: undefined }`, which an optional property accepted and which reached the guard's handler with an undefined param.
|
|
9
|
+
|
|
10
|
+
**New in 4.8:**
|
|
11
|
+
|
|
12
|
+
- **Grouped cache keys**: `LambderDdbCache` keys may be a `{ pk, sk }` pair instead of a string, which stores related entries in one partition: `{ pk: "division:ist-34", sk: "1700:1800" }` keeps every cached window of one division together. `deletePartition(pk)` then drops the whole group without knowing which sort keys exist, and `listSortKeys(pk, { prefix, limit })` reads back what is currently cached under it. The group invalidation a cache of derived, per-entity values needs, in place of remembering every key ever written or waiting out the TTL. Reads stay one request, and the memory layer, single-flight and fill lease stay per entry. Only the `pk` part is hashed, so the sort key is queryable; a caller's `#` is escaped rather than refused (`~`→`~0`, `#`→`~1`). Plain string keys keep their exact item layout, so a live table needs no migration and both forms can share a partition.
|
|
13
|
+
- **`guards` on the API contract**: each contract entry now carries the `guards` option exactly as declared (`ApiContractType["getUser"]["guards"]` is the literal `{ readonly orgPermission: "USERS.MANAGE" }`), so a client-side map of what an API needs can be pinned to the server's own declaration with `satisfies` instead of a test that reads the server source.
|
|
14
|
+
|
|
5
15
|
**New in 4.7:**
|
|
6
16
|
|
|
7
17
|
- **Compressed request payloads**: `requestCompression` on `LambderCaller` gzips the payload of any call whose JSON reaches a threshold (`true` is `{ minBytes: 4096 }`), sending it as `payloadGz` beside its byte length instead of `payload` whenever that is actually smaller; the server restores it before rate-limit key slices, guards and input validation, so no call site, handler or schema changes. Chiefly a way to fit a large payload under Lambda's ~6MB invoke cap, which applies to the compressed bytes. The envelope stays `application/json` with its routing fields in plain text, so gateways, CDNs and mocks are unaffected. `maxRequestPayloadBytes` (default 20MB) bounds what a body may expand to.
|
|
@@ -175,6 +185,19 @@ export type ApiContractType = typeof lambder.ApiContract;
|
|
|
175
185
|
export const handler = lambder.getHandler();
|
|
176
186
|
```
|
|
177
187
|
|
|
188
|
+
Each contract entry carries the API's `input` and `output`, its `guardInputs` when a guardInput-mode guard applies, and its `guards` option exactly as declared (`ApiContractType["getUser"]["guards"]` is the literal `{ readonly orgPermission: "USERS.MANAGE" }`). A client that keeps its own map of what an API needs, to decide whether to render a screen before calling, pins that map to the declarations with `satisfies` instead of a test that reads the server source:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
type PermissionNeededBy<K extends keyof ApiContractType> =
|
|
192
|
+
ApiContractType[K] extends { guards: { orgPermission: infer N } } ? N : never;
|
|
193
|
+
|
|
194
|
+
const NEEDS = {
|
|
195
|
+
getUser: "USERS.MANAGE",
|
|
196
|
+
} as const satisfies { [K in keyof ApiContractType]?: PermissionNeededBy<K> };
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Renaming the permission on the server, or moving the API to a different one, then fails the client's map to compile. Make the mapped type non-optional (over the guarded API names) when the map must also stay complete as guarded APIs are added.
|
|
200
|
+
|
|
178
201
|
### Adding Routes
|
|
179
202
|
|
|
180
203
|
```typescript
|
|
@@ -711,7 +734,7 @@ lambder.addApi("public.resetPassword", {
|
|
|
711
734
|
input: z.object({ email: z.string().email() }),
|
|
712
735
|
output: z.object({ ok: z.boolean() }),
|
|
713
736
|
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
|
|
714
|
-
guards: "captcha", // one name, a list of names, or a { name: param } map
|
|
737
|
+
guards: "captcha", // one name, a non-empty list of names, or a non-empty { name: param } map
|
|
715
738
|
}, handler);
|
|
716
739
|
|
|
717
740
|
lambder.addSessionApi("secure.order.create", {
|
|
@@ -748,6 +771,28 @@ const lambder = initLambder<SessionData>().create({
|
|
|
748
771
|
lambder.addSessionApi("secure.order.create", { input, output, guards: { orgPermission: "ORDERS.CREATE" } }, handler);
|
|
749
772
|
lambder.addSessionApi("secure.me.logOut", { input, output, guards: "sessionOnly" }, handler);
|
|
750
773
|
lambder.addSessionApi("secure.report.list", { input, output }, handler); // compile error: which guard?
|
|
774
|
+
lambder.addSessionApi("secure.report.list", { input, output, guards: {} }, handler); // compile error: {} declares no guard
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
**The same for public APIs (`requirePublicApiGuards`)**: public APIs are open by default, and that remains the default. An app whose public surface has grown past a handful of endpoints can turn `requirePublicApiGuards: true` on to make each one's openness a written decision instead of an omission. Not every public endpoint has a control that can be hoisted into a guard (an endpoint that checks a password *is* the check), so the vocabulary an app declares here is usually a real guard for what is a genuine precondition, plus named no-op guards for the rest. The two flags are independent; either or both may be on.
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
const lambder = initLambder<SessionData>().create({
|
|
781
|
+
apiPath: "/api",
|
|
782
|
+
guards: {
|
|
783
|
+
deviceToken: lambderGuard({ apiInput: z.object({ deviceToken: z.string().min(20) }), handler: (_c, { deviceToken }) => requireDevice(deviceToken) }),
|
|
784
|
+
// Anyone may call, and the param records why: `grep "open:"` lists every public door.
|
|
785
|
+
open: lambderGuard({ handler: (_c, _p, _r, _reason: string) => {} }),
|
|
786
|
+
// This endpoint establishes identity; the proof is the handler's own work.
|
|
787
|
+
credentialFlow: lambderGuard({ handler: () => {} }),
|
|
788
|
+
},
|
|
789
|
+
requirePublicApiGuards: true,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
lambder.addApi("public.device.report", { input, output, guards: "deviceToken" }, handler);
|
|
793
|
+
lambder.addApi("public.translations", { input, output, guards: { open: "Static strings already in the bundle." } }, handler);
|
|
794
|
+
lambder.addApi("public.login", { input, output, guards: "credentialFlow" }, handler);
|
|
795
|
+
lambder.addApi("public.search", { input, output }, handler); // compile error: open to anyone, or authorized how?
|
|
751
796
|
```
|
|
752
797
|
|
|
753
798
|
For api modules split across files, DERIVE the annotation type from the real instance instead of writing it by hand: create the instance next to the policy declarations and export `typeof` it. The type can never drift from what actually runs, and modules import it without a cycle (the app file imports no modules):
|
|
@@ -783,7 +828,7 @@ Also enforced at registration: **duplicate API names throw** (dispatch is first-
|
|
|
783
828
|
|
|
784
829
|
### DynamoDB Cache (LambderDdbCache)
|
|
785
830
|
|
|
786
|
-
Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values (the shared `compression` option), in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key,
|
|
831
|
+
Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values (the shared `compression` option), in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, fail-open semantics, and optional grouped keys for group invalidation. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
|
|
787
832
|
|
|
788
833
|
```typescript
|
|
789
834
|
import { LambderDdbCache } from "lambder";
|
|
@@ -799,6 +844,13 @@ const city = await cache.getOrSet(`city:${slug}`, async () => fetchCityFromDb(sl
|
|
|
799
844
|
ttlSeconds: 7 * 24 * 3600,
|
|
800
845
|
});
|
|
801
846
|
// Also: cache.get(key), cache.set(key, value, { ttlSeconds }), cache.has(key), cache.delete(key)
|
|
847
|
+
|
|
848
|
+
// A key can also be a { pk, sk } pair, which groups related entries under one
|
|
849
|
+
// partition so the whole group can be invalidated without listing its members:
|
|
850
|
+
const window = { pk: `division:${divisionId}`, sk: `${from}:${to}` };
|
|
851
|
+
await cache.getOrSet(window, () => loadDivision(divisionId, from, to));
|
|
852
|
+
await cache.deletePartition(`division:${divisionId}`); // every cached window of it
|
|
853
|
+
await cache.listSortKeys(`division:${divisionId}`); // ["1700:1800", "1700:1900"]
|
|
802
854
|
```
|
|
803
855
|
|
|
804
856
|
### Typed Translations (createLambderI18n)
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
21
21
|
type Path = `/${string}`;
|
|
22
22
|
type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
23
23
|
type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
24
|
-
type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any>) => void | Promise<void>;
|
|
24
|
+
type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any, any>) => void | Promise<void>;
|
|
25
25
|
/** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
|
|
26
26
|
type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
|
|
27
27
|
type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
|
|
@@ -157,19 +157,38 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
157
157
|
* from. Default: false.
|
|
158
158
|
*/
|
|
159
159
|
requireSessionApiGuards?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* The same for public APIs: every addApi must declare `guards`, at the
|
|
162
|
+
* type level and at registration.
|
|
163
|
+
*
|
|
164
|
+
* Public APIs are open by default and that is the right default, so this
|
|
165
|
+
* is off unless an app decides otherwise. What it buys an app that turns
|
|
166
|
+
* it on is that a public endpoint's openness becomes a written decision
|
|
167
|
+
* rather than an omission: the ones anybody may call declare a named no-op
|
|
168
|
+
* guard carrying the reason, and the ones that authorize their caller some
|
|
169
|
+
* other way (a signature, a device secret, a one-shot token) name where
|
|
170
|
+
* that happens. One grep over the guard names then lists every public
|
|
171
|
+
* door and why it is open, which is the review question a growing public
|
|
172
|
+
* surface makes expensive to answer any other way. Needs a guards map to
|
|
173
|
+
* pick from. Default: false.
|
|
174
|
+
*/
|
|
175
|
+
requirePublicApiGuards?: boolean;
|
|
160
176
|
/** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
|
|
161
177
|
idempotency?: LambderApiIdempotencyConfig;
|
|
162
178
|
};
|
|
163
179
|
/**
|
|
164
|
-
* The `guards` field of
|
|
165
|
-
*
|
|
166
|
-
* authorization declaration cannot be forgotten at the type level.
|
|
180
|
+
* The `guards` field of an API's options: optional by default, required once
|
|
181
|
+
* create() received the require*ApiGuards flag for that kind of API, so that
|
|
182
|
+
* an authorization declaration cannot be forgotten at the type level.
|
|
183
|
+
*
|
|
184
|
+
* One type for both kinds: the requirement is the same shape either way, and
|
|
185
|
+
* only which flag switches it on differs.
|
|
167
186
|
*/
|
|
168
|
-
type
|
|
169
|
-
/** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Required on this instance
|
|
187
|
+
type LambderRequirableGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
|
|
188
|
+
/** Named guards, run in declared order before input validation: a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Required on this instance: an API that needs no authorization declares a named no-op guard, so every opt-out is explicit and one grep lists them all. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
170
189
|
guards: TGuardsOpt;
|
|
171
190
|
} : {
|
|
172
|
-
/** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
191
|
+
/** Named guards, run in declared order before input validation: a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
173
192
|
guards?: TGuardsOpt;
|
|
174
193
|
};
|
|
175
194
|
/**
|
|
@@ -184,6 +203,7 @@ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequire
|
|
|
184
203
|
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
185
204
|
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
186
205
|
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
206
|
+
* @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
|
|
187
207
|
*
|
|
188
208
|
* @example
|
|
189
209
|
* ```typescript
|
|
@@ -194,7 +214,7 @@ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequire
|
|
|
194
214
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
195
215
|
* ```
|
|
196
216
|
*/
|
|
197
|
-
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false> {
|
|
217
|
+
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false, _TPublicGuardsRequired extends boolean = false> {
|
|
198
218
|
apiPath: string;
|
|
199
219
|
apiVersion: null | string;
|
|
200
220
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
@@ -228,6 +248,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
228
248
|
private finalizeOptions;
|
|
229
249
|
private maxRequestPayloadBytes;
|
|
230
250
|
private requireSessionApiGuards;
|
|
251
|
+
private requirePublicApiGuards;
|
|
231
252
|
private lambderSessionManager?;
|
|
232
253
|
private sessionCookieOptions;
|
|
233
254
|
private sessionTokenCookieKey;
|
|
@@ -275,20 +296,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
275
296
|
addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
|
|
276
297
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
277
298
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
278
|
-
use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
|
|
299
|
+
use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
|
|
279
300
|
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
|
|
280
301
|
input: TInput;
|
|
281
302
|
output: TOutput;
|
|
282
303
|
} & {
|
|
283
304
|
/** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
|
|
284
305
|
rateLimit?: TRateOpt;
|
|
285
|
-
/** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
286
|
-
guards?: TGuardsOpt;
|
|
287
306
|
/** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
|
|
288
307
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
289
308
|
ttlSeconds?: number;
|
|
290
309
|
}) : never;
|
|
291
|
-
}, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt
|
|
310
|
+
} & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
|
|
292
311
|
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
|
|
293
312
|
input: TInput;
|
|
294
313
|
output: TOutput;
|
|
@@ -299,7 +318,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
299
318
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
300
319
|
ttlSeconds?: number;
|
|
301
320
|
}) : never;
|
|
302
|
-
} &
|
|
321
|
+
} & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
|
|
303
322
|
/**
|
|
304
323
|
* Fetch the session or short-circuit the request: API calls get the
|
|
305
324
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
@@ -382,5 +401,5 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
382
401
|
export declare const initLambder: <TSessionData = any>() => {
|
|
383
402
|
create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
|
|
384
403
|
policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
|
|
385
|
-
} ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false>;
|
|
404
|
+
} ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false, TOptions["requirePublicApiGuards"] extends true ? true : false>;
|
|
386
405
|
};
|
package/dist/core/Lambder.js
CHANGED
|
@@ -24,6 +24,7 @@ import { DEFAULT_MAX_REQUEST_PAYLOAD_BYTES } from "../shared/LambderRequestPaylo
|
|
|
24
24
|
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
25
25
|
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
26
26
|
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
27
|
+
* @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
|
|
27
28
|
*
|
|
28
29
|
* @example
|
|
29
30
|
* ```typescript
|
|
@@ -68,6 +69,7 @@ export default class Lambder {
|
|
|
68
69
|
finalizeOptions;
|
|
69
70
|
maxRequestPayloadBytes;
|
|
70
71
|
requireSessionApiGuards;
|
|
72
|
+
requirePublicApiGuards;
|
|
71
73
|
lambderSessionManager;
|
|
72
74
|
sessionCookieOptions = {};
|
|
73
75
|
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
@@ -116,8 +118,10 @@ export default class Lambder {
|
|
|
116
118
|
if (options.idempotency)
|
|
117
119
|
this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
|
|
118
120
|
this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
|
|
122
|
+
const requireFlag = this.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
|
|
123
|
+
if ((this.requireSessionApiGuards || this.requirePublicApiGuards) && !options.guards) {
|
|
124
|
+
throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
setRouteFallbackHandler(routeFallbackHandler) {
|
|
@@ -215,9 +219,13 @@ export default class Lambder {
|
|
|
215
219
|
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
216
220
|
}
|
|
217
221
|
this.registeredApiNames.add(name);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
|
|
223
|
+
if (guardsRequired && options.guards === undefined) {
|
|
224
|
+
const optOut = mode === "session"
|
|
225
|
+
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
226
|
+
: "the named no-op guard that records why anyone may call it";
|
|
227
|
+
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
228
|
+
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
221
229
|
}
|
|
222
230
|
const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
|
|
223
231
|
if (!usesPolicies)
|
package/dist/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { compressText, restoreBoundedText, LambderCompressionError, LAMBDER_REST
|
|
|
29
29
|
export type { LambderRestoreFailure } from "./shared/LambderCompressionCodec.js";
|
|
30
30
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
31
31
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
32
|
-
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
|
|
32
|
+
export type { LambderCacheKey, LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, LambderDdbCacheListOptions, } from "./stores/LambderDdbCache.js";
|
|
33
33
|
export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
|
|
34
34
|
export type { LambderDdbRateLimiterOptions, LambderRateLimitWindow, LambderRateLimitPolicy, LambderRateLimitExceeded, LambderRateLimitResult, } from "./stores/LambderDdbRateLimiter.js";
|
|
35
35
|
export { LambderDdbIdempotency } from "./stores/LambderDdbIdempotency.js";
|
|
@@ -164,18 +164,42 @@ export type LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession extend
|
|
|
164
164
|
param: undefined;
|
|
165
165
|
} ? K & string : never;
|
|
166
166
|
}[LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession> & keyof TGuards];
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
* paramless names, or an object map that can carry each guard's param
|
|
170
|
-
* (`true` enables a paramless guard). Map entries run in insertion order.
|
|
171
|
-
*/
|
|
172
|
-
export type LambderGuardsOption<TGuards, TPayload, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession> | readonly LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>[] | {
|
|
167
|
+
/** The map form's full shape: every declarable guard name, each carrying its own param type. */
|
|
168
|
+
type LambderGuardsMap<TGuards, TPayload, TIncludeSession extends boolean> = {
|
|
173
169
|
readonly [K in LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession> & keyof TGuards]?: TGuards[K] extends {
|
|
174
170
|
param: undefined;
|
|
175
171
|
} ? true : TGuards[K] extends {
|
|
176
172
|
param: infer P;
|
|
177
173
|
} ? P : true;
|
|
178
174
|
};
|
|
175
|
+
/**
|
|
176
|
+
* The map form with AT LEAST ONE entry: the union, over every declarable
|
|
177
|
+
* name, of "this one required and the rest optional".
|
|
178
|
+
*
|
|
179
|
+
* An all-optional map is inhabited by `{}`, which would let `guards: {}`
|
|
180
|
+
* satisfy requireSessionApiGuards / requirePublicApiGuards at the type level
|
|
181
|
+
* while declaring no guard at all: the option is present, so the required-field
|
|
182
|
+
* check passes, and it normalizes to zero entries, so nothing runs. Requiring
|
|
183
|
+
* the chosen key also rejects `{ theGuard: undefined }`, which an optional
|
|
184
|
+
* property accepts and which would otherwise reach the guard's handler with an
|
|
185
|
+
* undefined param.
|
|
186
|
+
*/
|
|
187
|
+
type LambderNonEmptyGuardsMap<TGuards, TPayload, TIncludeSession extends boolean, TMap = LambderGuardsMap<TGuards, TPayload, TIncludeSession>> = {
|
|
188
|
+
[K in keyof TMap]-?: Required<Pick<TMap, K>> & Omit<TMap, K>;
|
|
189
|
+
}[keyof TMap];
|
|
190
|
+
/**
|
|
191
|
+
* The per-API `guards` option: one paramless guard name, a non-empty ordered
|
|
192
|
+
* list of paramless names, or a non-empty object map that can carry each
|
|
193
|
+
* guard's param (`true` enables a paramless guard). Map entries run in
|
|
194
|
+
* insertion order.
|
|
195
|
+
*
|
|
196
|
+
* Every form is non-empty by construction, so declaring the option is always
|
|
197
|
+
* declaring a guard. See LambderNonEmptyGuardsMap.
|
|
198
|
+
*/
|
|
199
|
+
export type LambderGuardsOption<TGuards, TPayload, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession> | readonly [
|
|
200
|
+
LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>,
|
|
201
|
+
...LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>[]
|
|
202
|
+
] | LambderNonEmptyGuardsMap<TGuards, TPayload, TIncludeSession>;
|
|
179
203
|
/**
|
|
180
204
|
* The typed ctx.guardData an API's handler sees: declared guards that return
|
|
181
205
|
* a value, keyed by name. Check-only (void) guards never appear.
|
|
@@ -48,7 +48,18 @@ export class LambderApiGuardsEngine {
|
|
|
48
48
|
}
|
|
49
49
|
/** Startup validation of one API registration's guards option. */
|
|
50
50
|
assertRegistration(apiName, mode, guardsOption) {
|
|
51
|
-
|
|
51
|
+
const entries = toGuardEntries(guardsOption);
|
|
52
|
+
// The runtime half of LambderNonEmptyGuardsMap. `guards: {}` and
|
|
53
|
+
// `guards: []` are present-but-empty: they satisfy the require*ApiGuards
|
|
54
|
+
// field check while running nothing, which is the one shape that turns a
|
|
55
|
+
// mandatory authorization declaration back into an optional one. The type
|
|
56
|
+
// rejects both; a plain-JS caller, a cast, or a spread that happened to
|
|
57
|
+
// produce an empty object lands here instead.
|
|
58
|
+
if (guardsOption !== undefined && entries.length === 0) {
|
|
59
|
+
throw new Error(`Lambder: API "${apiName}" declares an empty guards option, which authorizes nothing. ` +
|
|
60
|
+
`Name the guard that authorizes it, or omit the option entirely.`);
|
|
61
|
+
}
|
|
62
|
+
for (const { name } of entries) {
|
|
52
63
|
const guardDef = this.guards[name];
|
|
53
64
|
if (!guardDef) {
|
|
54
65
|
throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Declare it in the guards option at creation.`);
|
|
@@ -11,6 +11,13 @@ export type ApiContractShape = Record<string, {
|
|
|
11
11
|
output: any;
|
|
12
12
|
/** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
|
|
13
13
|
guardInputs?: any;
|
|
14
|
+
/**
|
|
15
|
+
* Present when the API declares guards: the `guards` option exactly as
|
|
16
|
+
* written at registration, so a client-side copy of "what does this API
|
|
17
|
+
* need" can be pinned to the server's own declaration with `satisfies`
|
|
18
|
+
* rather than kept honest by a test that reads the source.
|
|
19
|
+
*/
|
|
20
|
+
guards?: any;
|
|
14
21
|
}>;
|
|
15
22
|
/** Envelope flags/channels the server may set beside (or instead of) the payload. */
|
|
16
23
|
export type LambderApiResponseConfig = {
|
|
@@ -29,13 +36,13 @@ export type LambderApiResponse<T> = LambderApiResponseConfig & {
|
|
|
29
36
|
/**
|
|
30
37
|
* Helper type for merging new API into existing contract during chaining
|
|
31
38
|
*/
|
|
32
|
-
export type MergeContract<Old, Name extends string, In, Out, GuardInputs = never> = Old & {
|
|
33
|
-
[K in Name]: [GuardInputs] extends [never] ? {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
export type MergeContract<Old, Name extends string, In, Out, GuardInputs = never, Guards = never> = Old & {
|
|
40
|
+
[K in Name]: ([GuardInputs] extends [never] ? {} : {
|
|
41
|
+
guardInputs: GuardInputs;
|
|
42
|
+
}) & ([Guards] extends [never] ? {} : {
|
|
43
|
+
guards: Guards;
|
|
44
|
+
}) & {
|
|
37
45
|
input: In;
|
|
38
46
|
output: Out;
|
|
39
|
-
guardInputs: GuardInputs;
|
|
40
47
|
};
|
|
41
48
|
};
|
|
@@ -19,6 +19,18 @@ export interface LambderDdbCacheOptions {
|
|
|
19
19
|
memoryMaxBytes?: number;
|
|
20
20
|
client?: DynamoDBClient;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Where a value lives. A plain string addresses one entry, as it always has.
|
|
24
|
+
* `{ pk, sk }` puts the entry in a partition it can share with others, so a
|
|
25
|
+
* group can be listed or dropped in one call: `{ pk: "division:ist-34", sk:
|
|
26
|
+
* "1700:1800" }` keeps every cached window of one division together.
|
|
27
|
+
* Only the `pk` part is hashed into the DynamoDB partition key; the sort key
|
|
28
|
+
* is stored readable, which is what makes prefix queries possible.
|
|
29
|
+
*/
|
|
30
|
+
export type LambderCacheKey = string | {
|
|
31
|
+
pk: string;
|
|
32
|
+
sk: string;
|
|
33
|
+
};
|
|
22
34
|
export interface LambderDdbCacheSetOptions {
|
|
23
35
|
ttlSeconds?: number;
|
|
24
36
|
}
|
|
@@ -26,6 +38,12 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
|
|
|
26
38
|
leaseSeconds?: number;
|
|
27
39
|
waitForFillMs?: number;
|
|
28
40
|
}
|
|
41
|
+
export interface LambderDdbCacheListOptions {
|
|
42
|
+
/** Only sort keys starting with this raw (unescaped) prefix. */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/** Cap on RESULTS, not on items read: the partition (or prefix range) is read either way. */
|
|
45
|
+
limit?: number;
|
|
46
|
+
}
|
|
29
47
|
/**
|
|
30
48
|
* Persistent JSON cache backed by DynamoDB.
|
|
31
49
|
*
|
|
@@ -42,6 +60,13 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
|
|
|
42
60
|
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
43
61
|
* table can be shared with LambderDdbRateLimiter (`RL#`) and
|
|
44
62
|
* LambderDdbIdempotency (`IDEM#`) without key collisions.
|
|
63
|
+
*
|
|
64
|
+
* A key may also be a `{ pk, sk }` pair, which groups entries under one
|
|
65
|
+
* partition so `deletePartition` and `listSortKeys` can work on the group
|
|
66
|
+
* without knowing its members. Plain-string keys keep the exact item layout
|
|
67
|
+
* they have always had (`meta`, `lock`, `chunk#...`), and grouped entries
|
|
68
|
+
* live beside them under `sk#<encoded sort key>#...`, so both forms can
|
|
69
|
+
* share a partition and a live table needs no migration.
|
|
45
70
|
*/
|
|
46
71
|
export declare class LambderDdbCache {
|
|
47
72
|
readonly tableName: string;
|
|
@@ -55,11 +80,28 @@ export declare class LambderDdbCache {
|
|
|
55
80
|
private readonly memory;
|
|
56
81
|
private readonly inFlight;
|
|
57
82
|
constructor(options: LambderDdbCacheOptions);
|
|
58
|
-
get<T>(key:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
get<T>(key: LambderCacheKey): Promise<T | undefined>;
|
|
84
|
+
private getByAddress;
|
|
85
|
+
has(key: LambderCacheKey): Promise<boolean>;
|
|
86
|
+
set<T>(key: LambderCacheKey, value: T, options?: LambderDdbCacheSetOptions): Promise<void>;
|
|
87
|
+
private setByAddress;
|
|
88
|
+
delete(key: LambderCacheKey): Promise<boolean>;
|
|
89
|
+
/**
|
|
90
|
+
* Drop every entry stored under one `pk`, without knowing which sort keys
|
|
91
|
+
* exist: the invalidation a group of related entries is worth grouping
|
|
92
|
+
* for. Returns the number of entries removed. In-memory copies held by
|
|
93
|
+
* OTHER Lambda containers still serve until their own TTL, as they do
|
|
94
|
+
* after a single-entry delete.
|
|
95
|
+
*/
|
|
96
|
+
deletePartition(partition: string): Promise<number>;
|
|
97
|
+
/**
|
|
98
|
+
* The live (unexpired) sort keys stored under one `pk`, in table order.
|
|
99
|
+
* Plain-string entries have no sort key, so they never appear here.
|
|
100
|
+
* Reading a partition whose values are chunked also reads those chunk
|
|
101
|
+
* items, so grouping very large values makes listing more expensive.
|
|
102
|
+
*/
|
|
103
|
+
listSortKeys(partition: string, options?: LambderDdbCacheListOptions): Promise<string[]>;
|
|
104
|
+
getOrSet<T>(key: LambderCacheKey, factory: () => Promise<T>, options?: LambderDdbCacheGetOrSetOptions): Promise<T>;
|
|
63
105
|
/**
|
|
64
106
|
* Cache infrastructure is best-effort for getOrSet: read, lease, or write
|
|
65
107
|
* failures return the loader value. Loader failures still propagate and the
|
|
@@ -71,14 +113,32 @@ export declare class LambderDdbCache {
|
|
|
71
113
|
private releaseLease;
|
|
72
114
|
private readManifest;
|
|
73
115
|
private readChunks;
|
|
116
|
+
/** Every item matching a partition (optionally a sort-key prefix), following pagination. */
|
|
117
|
+
private queryItems;
|
|
118
|
+
private deleteItems;
|
|
74
119
|
private invalidateManifest;
|
|
75
120
|
private batchWrite;
|
|
76
121
|
/** The JSON text of a stored payload. */
|
|
77
122
|
private decode;
|
|
78
123
|
private remember;
|
|
79
124
|
private normalizeKey;
|
|
125
|
+
private normalizePartition;
|
|
126
|
+
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
127
|
+
private memoryKeyOf;
|
|
80
128
|
private partitionKey;
|
|
129
|
+
/**
|
|
130
|
+
* One of an entry's item keys. A plain-string entry keeps the bare
|
|
131
|
+
* suffix it has always used; a grouped one nests under its escaped sort
|
|
132
|
+
* key, whose trailing `#` is an unambiguous boundary because an escaped
|
|
133
|
+
* sort key never contains a bare `#`.
|
|
134
|
+
*/
|
|
135
|
+
private itemSortKey;
|
|
136
|
+
/** The prefix covering every item of a grouped entry; null for a plain-string entry, which owns the bare item keys instead. */
|
|
137
|
+
private entryItemPrefix;
|
|
138
|
+
private isManifestSortKey;
|
|
81
139
|
private chunkSortKey;
|
|
140
|
+
/** Drop every in-memory copy belonging to one partition. */
|
|
141
|
+
private forgetPartition;
|
|
82
142
|
private nowSeconds;
|
|
83
143
|
private isConditionalFailure;
|
|
84
144
|
}
|
|
@@ -10,10 +10,26 @@ const DEFAULT_MAX_VALUE_BYTES = 32 * 1024 * 1024;
|
|
|
10
10
|
const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
|
|
11
11
|
const META_SORT_KEY = "meta";
|
|
12
12
|
const LOCK_SORT_KEY = "lock";
|
|
13
|
+
const CHUNK_SORT_KEY_PREFIX = "chunk#";
|
|
14
|
+
/** Item-key prefix that separates entries addressed with a sort key from plain-key entries sharing the partition. */
|
|
15
|
+
const SORT_KEY_MARKER = "sk#";
|
|
16
|
+
/** Budget for one encoded sort key, leaving room for the marker and the longest item suffix inside DynamoDB's 1024-byte range key limit. */
|
|
17
|
+
const MAX_SORT_KEY_BYTES = 900;
|
|
13
18
|
const BATCH_WRITE_LIMIT = 25;
|
|
14
19
|
const MAX_BATCH_RETRIES = 8;
|
|
15
20
|
/** Every value compressed by default; see the `compression` option. */
|
|
16
21
|
const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
22
|
+
/**
|
|
23
|
+
* `#` separates the store's own item-key segments, so a caller's `#` is
|
|
24
|
+
* escaped rather than refused: `~` becomes `~0` and `#` becomes `~1`. An
|
|
25
|
+
* encoded sort key therefore never contains a bare `#`, which keeps
|
|
26
|
+
* `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
|
|
27
|
+
* per-character, so a prefix of the raw key stays a prefix of the encoded
|
|
28
|
+
* one; only the sort ORDER of keys that contain `#` or `~` shifts, since
|
|
29
|
+
* both encode into the `~` range.
|
|
30
|
+
*/
|
|
31
|
+
const encodeSortKey = (value) => value.replace(/~/g, "~0").replace(/#/g, "~1");
|
|
32
|
+
const decodeSortKey = (value) => value.replace(/~([01])/g, (_match, code) => code === "0" ? "~" : "#");
|
|
17
33
|
// Node builtins are loaded lazily through node-polyfills so this module can
|
|
18
34
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
19
35
|
// breaking; using the cache at runtime still requires Node. Brotli helpers
|
|
@@ -56,6 +72,13 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
56
72
|
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
57
73
|
* table can be shared with LambderDdbRateLimiter (`RL#`) and
|
|
58
74
|
* LambderDdbIdempotency (`IDEM#`) without key collisions.
|
|
75
|
+
*
|
|
76
|
+
* A key may also be a `{ pk, sk }` pair, which groups entries under one
|
|
77
|
+
* partition so `deletePartition` and `listSortKeys` can work on the group
|
|
78
|
+
* without knowing its members. Plain-string keys keep the exact item layout
|
|
79
|
+
* they have always had (`meta`, `lock`, `chunk#...`), and grouped entries
|
|
80
|
+
* live beside them under `sk#<encoded sort key>#...`, so both forms can
|
|
81
|
+
* share a partition and a live table needs no migration.
|
|
59
82
|
*/
|
|
60
83
|
export class LambderDdbCache {
|
|
61
84
|
tableName;
|
|
@@ -94,8 +117,10 @@ export class LambderDdbCache {
|
|
|
94
117
|
this.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
|
|
95
118
|
}
|
|
96
119
|
async get(key) {
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
return await this.getByAddress(this.normalizeKey(key));
|
|
121
|
+
}
|
|
122
|
+
async getByAddress(address) {
|
|
123
|
+
const cached = this.memory?.get(address.memoryKey);
|
|
99
124
|
const nowSeconds = this.nowSeconds();
|
|
100
125
|
if (cached && cached.expiresAt > nowSeconds) {
|
|
101
126
|
try {
|
|
@@ -106,13 +131,13 @@ export class LambderDdbCache {
|
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
if (cached)
|
|
109
|
-
this.memory?.delete(
|
|
110
|
-
const pk = await this.partitionKey(
|
|
111
|
-
const manifest = await this.readManifest(pk);
|
|
134
|
+
this.memory?.delete(address.memoryKey);
|
|
135
|
+
const pk = await this.partitionKey(address.partition);
|
|
136
|
+
const manifest = await this.readManifest(pk, address);
|
|
112
137
|
if (!manifest || manifest.expiresAt <= nowSeconds)
|
|
113
138
|
return undefined;
|
|
114
139
|
try {
|
|
115
|
-
const stored = manifest.inlineData ?? await this.readChunks(pk, manifest);
|
|
140
|
+
const stored = manifest.inlineData ?? await this.readChunks(pk, address, manifest);
|
|
116
141
|
if (stored.length !== manifest.storedBytes) {
|
|
117
142
|
throw new Error("stored byte length does not match manifest");
|
|
118
143
|
}
|
|
@@ -121,28 +146,30 @@ export class LambderDdbCache {
|
|
|
121
146
|
}
|
|
122
147
|
const json = await this.decode(stored, manifest.encoding, manifest.uncompressedBytes);
|
|
123
148
|
const parsed = JSON.parse(json);
|
|
124
|
-
this.remember(
|
|
149
|
+
this.remember(address.memoryKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
|
|
125
150
|
return parsed;
|
|
126
151
|
}
|
|
127
152
|
catch (error) {
|
|
128
|
-
await this.invalidateManifest(pk, manifest.version);
|
|
153
|
+
await this.invalidateManifest(pk, address, manifest.version);
|
|
129
154
|
console.warn(`Ignoring corrupt DynamoDB cache entry in ${this.namespace}`, error);
|
|
130
155
|
return undefined;
|
|
131
156
|
}
|
|
132
157
|
}
|
|
133
158
|
async has(key) {
|
|
134
|
-
const
|
|
135
|
-
const cached = this.memory?.get(
|
|
159
|
+
const address = this.normalizeKey(key);
|
|
160
|
+
const cached = this.memory?.get(address.memoryKey);
|
|
136
161
|
const nowSeconds = this.nowSeconds();
|
|
137
162
|
if (cached?.expiresAt && cached.expiresAt > nowSeconds)
|
|
138
163
|
return true;
|
|
139
164
|
if (cached)
|
|
140
|
-
this.memory?.delete(
|
|
141
|
-
const manifest = await this.readManifest(await this.partitionKey(
|
|
165
|
+
this.memory?.delete(address.memoryKey);
|
|
166
|
+
const manifest = await this.readManifest(await this.partitionKey(address.partition), address);
|
|
142
167
|
return !!manifest && manifest.expiresAt > nowSeconds;
|
|
143
168
|
}
|
|
144
169
|
async set(key, value, options = {}) {
|
|
145
|
-
|
|
170
|
+
return await this.setByAddress(this.normalizeKey(key), value, options);
|
|
171
|
+
}
|
|
172
|
+
async setByAddress(address, value, options) {
|
|
146
173
|
const ttlSeconds = positiveInteger(options.ttlSeconds ?? this.defaultTtlSeconds, "ttlSeconds");
|
|
147
174
|
const json = JSON.stringify(value);
|
|
148
175
|
if (json === undefined)
|
|
@@ -157,7 +184,7 @@ export class LambderDdbCache {
|
|
|
157
184
|
if (stored.length > this.maxValueBytes) {
|
|
158
185
|
throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
|
|
159
186
|
}
|
|
160
|
-
const pk = await this.partitionKey(
|
|
187
|
+
const pk = await this.partitionKey(address.partition);
|
|
161
188
|
const version = `${Date.now().toString(36)}-${await randomUUID()}`;
|
|
162
189
|
const expiresAt = this.nowSeconds() + ttlSeconds;
|
|
163
190
|
const chunks = [];
|
|
@@ -171,7 +198,7 @@ export class LambderDdbCache {
|
|
|
171
198
|
PutRequest: {
|
|
172
199
|
Item: {
|
|
173
200
|
pk: { S: pk },
|
|
174
|
-
sk: { S: this.chunkSortKey(version, index) },
|
|
201
|
+
sk: { S: this.chunkSortKey(address, version, index) },
|
|
175
202
|
data: { B: chunk },
|
|
176
203
|
expiresAt: { N: String(expiresAt) },
|
|
177
204
|
},
|
|
@@ -182,7 +209,7 @@ export class LambderDdbCache {
|
|
|
182
209
|
TableName: this.tableName,
|
|
183
210
|
Item: {
|
|
184
211
|
pk: { S: pk },
|
|
185
|
-
sk: { S: META_SORT_KEY },
|
|
212
|
+
sk: { S: this.itemSortKey(address, META_SORT_KEY) },
|
|
186
213
|
version: { S: version },
|
|
187
214
|
chunkCount: { N: String(chunks.length) },
|
|
188
215
|
storedBytes: { N: String(stored.length) },
|
|
@@ -194,41 +221,70 @@ export class LambderDdbCache {
|
|
|
194
221
|
...(inline ? { data: { B: stored } } : {}),
|
|
195
222
|
},
|
|
196
223
|
}));
|
|
197
|
-
this.remember(
|
|
224
|
+
this.remember(address.memoryKey, stored, encoding, input.length, expiresAt);
|
|
198
225
|
}
|
|
199
226
|
async delete(key) {
|
|
200
|
-
const
|
|
201
|
-
const pk = await this.partitionKey(
|
|
202
|
-
this.memory?.delete(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
const address = this.normalizeKey(key);
|
|
228
|
+
const pk = await this.partitionKey(address.partition);
|
|
229
|
+
this.memory?.delete(address.memoryKey);
|
|
230
|
+
// A grouped entry owns one contiguous item range; a plain-string one
|
|
231
|
+
// owns the bare item keys, so it must leave any grouped entries
|
|
232
|
+
// sharing its partition alone.
|
|
233
|
+
const prefix = this.entryItemPrefix(address);
|
|
234
|
+
const items = await this.queryItems(pk, { prefix, projection: "#pk, #sk" });
|
|
235
|
+
const owned = prefix ? items : items.filter((item) => !item.sk?.S?.startsWith(SORT_KEY_MARKER));
|
|
236
|
+
await this.deleteItems(owned);
|
|
237
|
+
return owned.length > 0;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Drop every entry stored under one `pk`, without knowing which sort keys
|
|
241
|
+
* exist: the invalidation a group of related entries is worth grouping
|
|
242
|
+
* for. Returns the number of entries removed. In-memory copies held by
|
|
243
|
+
* OTHER Lambda containers still serve until their own TTL, as they do
|
|
244
|
+
* after a single-entry delete.
|
|
245
|
+
*/
|
|
246
|
+
async deletePartition(partition) {
|
|
247
|
+
const normalized = this.normalizePartition(partition);
|
|
248
|
+
const pk = await this.partitionKey(normalized);
|
|
249
|
+
this.forgetPartition(normalized);
|
|
250
|
+
const items = await this.queryItems(pk, { projection: "#pk, #sk" });
|
|
251
|
+
await this.deleteItems(items);
|
|
252
|
+
return items.filter((item) => this.isManifestSortKey(item.sk?.S)).length;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The live (unexpired) sort keys stored under one `pk`, in table order.
|
|
256
|
+
* Plain-string entries have no sort key, so they never appear here.
|
|
257
|
+
* Reading a partition whose values are chunked also reads those chunk
|
|
258
|
+
* items, so grouping very large values makes listing more expensive.
|
|
259
|
+
*/
|
|
260
|
+
async listSortKeys(partition, options = {}) {
|
|
261
|
+
const pk = await this.partitionKey(this.normalizePartition(partition));
|
|
262
|
+
const prefix = `${SORT_KEY_MARKER}${encodeSortKey(options.prefix ?? "")}`;
|
|
263
|
+
const limit = options.limit === undefined ? undefined : positiveInteger(options.limit, "limit");
|
|
264
|
+
const nowSeconds = this.nowSeconds();
|
|
265
|
+
const items = await this.queryItems(pk, { prefix, projection: "#sk, #expiresAt", extraNames: { "#expiresAt": "expiresAt" } });
|
|
266
|
+
const sortKeys = [];
|
|
267
|
+
for (const item of items) {
|
|
268
|
+
const sk = item.sk?.S;
|
|
269
|
+
if (!sk || !this.isManifestSortKey(sk))
|
|
270
|
+
continue;
|
|
271
|
+
if (Number(item.expiresAt?.N) <= nowSeconds)
|
|
272
|
+
continue;
|
|
273
|
+
sortKeys.push(decodeSortKey(sk.slice(SORT_KEY_MARKER.length, -(META_SORT_KEY.length + 1))));
|
|
274
|
+
if (limit !== undefined && sortKeys.length >= limit)
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
return sortKeys;
|
|
222
278
|
}
|
|
223
279
|
async getOrSet(key, factory, options = {}) {
|
|
224
|
-
const
|
|
225
|
-
const current = this.inFlight.get(
|
|
280
|
+
const address = this.normalizeKey(key);
|
|
281
|
+
const current = this.inFlight.get(address.memoryKey);
|
|
226
282
|
if (current)
|
|
227
283
|
return current;
|
|
228
|
-
const fill = this.getOrSetFailOpen(
|
|
229
|
-
this.inFlight.delete(
|
|
284
|
+
const fill = this.getOrSetFailOpen(address, factory, options).finally(() => {
|
|
285
|
+
this.inFlight.delete(address.memoryKey);
|
|
230
286
|
});
|
|
231
|
-
this.inFlight.set(
|
|
287
|
+
this.inFlight.set(address.memoryKey, fill);
|
|
232
288
|
return fill;
|
|
233
289
|
}
|
|
234
290
|
/**
|
|
@@ -236,7 +292,7 @@ export class LambderDdbCache {
|
|
|
236
292
|
* failures return the loader value. Loader failures still propagate and the
|
|
237
293
|
* loader is never repeated after it has completed successfully.
|
|
238
294
|
*/
|
|
239
|
-
async getOrSetFailOpen(
|
|
295
|
+
async getOrSetFailOpen(address, factory, options) {
|
|
240
296
|
let factoryStarted = false;
|
|
241
297
|
let factoryCompleted = false;
|
|
242
298
|
let factoryValue;
|
|
@@ -247,64 +303,64 @@ export class LambderDdbCache {
|
|
|
247
303
|
return factoryValue;
|
|
248
304
|
};
|
|
249
305
|
try {
|
|
250
|
-
const existing = await this.
|
|
306
|
+
const existing = await this.getByAddress(address);
|
|
251
307
|
if (existing !== undefined)
|
|
252
308
|
return existing;
|
|
253
|
-
return await this.fill(
|
|
309
|
+
return await this.fill(address, trackedFactory, options);
|
|
254
310
|
}
|
|
255
311
|
catch (error) {
|
|
256
312
|
if (factoryStarted && !factoryCompleted)
|
|
257
313
|
throw error;
|
|
258
|
-
console.error(`DynamoDB cache failed open in ${this.namespace} for ${
|
|
314
|
+
console.error(`DynamoDB cache failed open in ${this.namespace} for ${address.memoryKey}`, error);
|
|
259
315
|
if (factoryCompleted)
|
|
260
316
|
return factoryValue;
|
|
261
317
|
return trackedFactory();
|
|
262
318
|
}
|
|
263
319
|
}
|
|
264
|
-
async fill(
|
|
320
|
+
async fill(address, factory, options) {
|
|
265
321
|
const leaseSeconds = positiveInteger(options.leaseSeconds ?? 15, "leaseSeconds");
|
|
266
322
|
const waitForFillMs = positiveInteger(options.waitForFillMs ?? 5_000, "waitForFillMs");
|
|
267
|
-
const pk = await this.partitionKey(
|
|
323
|
+
const pk = await this.partitionKey(address.partition);
|
|
268
324
|
const owner = await randomUUID();
|
|
269
|
-
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
325
|
+
if (await this.acquireLease(pk, address, owner, leaseSeconds)) {
|
|
270
326
|
try {
|
|
271
327
|
const value = await factory();
|
|
272
|
-
await this.
|
|
328
|
+
await this.setByAddress(address, value, { ttlSeconds: options.ttlSeconds });
|
|
273
329
|
return value;
|
|
274
330
|
}
|
|
275
331
|
finally {
|
|
276
|
-
await this.releaseLease(pk, owner);
|
|
332
|
+
await this.releaseLease(pk, address, owner);
|
|
277
333
|
}
|
|
278
334
|
}
|
|
279
335
|
const deadline = Date.now() + waitForFillMs;
|
|
280
336
|
let delay = 50;
|
|
281
337
|
while (Date.now() < deadline) {
|
|
282
338
|
await sleep(delay + Math.floor(Math.random() * 25));
|
|
283
|
-
const value = await this.
|
|
339
|
+
const value = await this.getByAddress(address);
|
|
284
340
|
if (value !== undefined)
|
|
285
341
|
return value;
|
|
286
|
-
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
342
|
+
if (await this.acquireLease(pk, address, owner, leaseSeconds)) {
|
|
287
343
|
try {
|
|
288
344
|
const loaded = await factory();
|
|
289
|
-
await this.
|
|
345
|
+
await this.setByAddress(address, loaded, { ttlSeconds: options.ttlSeconds });
|
|
290
346
|
return loaded;
|
|
291
347
|
}
|
|
292
348
|
finally {
|
|
293
|
-
await this.releaseLease(pk, owner);
|
|
349
|
+
await this.releaseLease(pk, address, owner);
|
|
294
350
|
}
|
|
295
351
|
}
|
|
296
352
|
delay = Math.min(delay * 2, 500);
|
|
297
353
|
}
|
|
298
354
|
throw new Error(`Timed out waiting for DynamoDB cache fill in ${this.namespace}`);
|
|
299
355
|
}
|
|
300
|
-
async acquireLease(pk, owner, leaseSeconds) {
|
|
356
|
+
async acquireLease(pk, address, owner, leaseSeconds) {
|
|
301
357
|
const now = this.nowSeconds();
|
|
302
358
|
try {
|
|
303
359
|
await this.client.send(new PutItemCommand({
|
|
304
360
|
TableName: this.tableName,
|
|
305
361
|
Item: {
|
|
306
362
|
pk: { S: pk },
|
|
307
|
-
sk: { S: LOCK_SORT_KEY },
|
|
363
|
+
sk: { S: this.itemSortKey(address, LOCK_SORT_KEY) },
|
|
308
364
|
owner: { S: owner },
|
|
309
365
|
expiresAt: { N: String(now + leaseSeconds) },
|
|
310
366
|
},
|
|
@@ -320,11 +376,11 @@ export class LambderDdbCache {
|
|
|
320
376
|
throw error;
|
|
321
377
|
}
|
|
322
378
|
}
|
|
323
|
-
async releaseLease(pk, owner) {
|
|
379
|
+
async releaseLease(pk, address, owner) {
|
|
324
380
|
try {
|
|
325
381
|
await this.client.send(new DeleteItemCommand({
|
|
326
382
|
TableName: this.tableName,
|
|
327
|
-
Key: { pk: { S: pk }, sk: { S: LOCK_SORT_KEY } },
|
|
383
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, LOCK_SORT_KEY) } },
|
|
328
384
|
ConditionExpression: "#owner = :owner",
|
|
329
385
|
ExpressionAttributeNames: { "#owner": "owner" },
|
|
330
386
|
ExpressionAttributeValues: { ":owner": { S: owner } },
|
|
@@ -336,10 +392,10 @@ export class LambderDdbCache {
|
|
|
336
392
|
}
|
|
337
393
|
}
|
|
338
394
|
}
|
|
339
|
-
async readManifest(pk) {
|
|
395
|
+
async readManifest(pk, address) {
|
|
340
396
|
const response = await this.client.send(new GetItemCommand({
|
|
341
397
|
TableName: this.tableName,
|
|
342
|
-
Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
|
|
398
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
343
399
|
ConsistentRead: false,
|
|
344
400
|
}));
|
|
345
401
|
const item = response.Item;
|
|
@@ -387,43 +443,60 @@ export class LambderDdbCache {
|
|
|
387
443
|
inlineData,
|
|
388
444
|
};
|
|
389
445
|
}
|
|
390
|
-
async readChunks(pk, manifest) {
|
|
391
|
-
const prefix =
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#data": "data" },
|
|
401
|
-
ExclusiveStartKey: cursor,
|
|
402
|
-
ConsistentRead: false,
|
|
403
|
-
}));
|
|
404
|
-
for (const item of response.Items ?? []) {
|
|
405
|
-
if (item.sk?.S && item.data?.B) {
|
|
406
|
-
chunks.push({ sk: item.sk.S, data: Buffer.from(item.data.B) });
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
cursor = response.LastEvaluatedKey;
|
|
410
|
-
} while (cursor);
|
|
446
|
+
async readChunks(pk, address, manifest) {
|
|
447
|
+
const prefix = this.itemSortKey(address, `${CHUNK_SORT_KEY_PREFIX}${manifest.version}#`);
|
|
448
|
+
const items = await this.queryItems(pk, {
|
|
449
|
+
prefix,
|
|
450
|
+
projection: "#sk, #data",
|
|
451
|
+
extraNames: { "#data": "data" },
|
|
452
|
+
});
|
|
453
|
+
const chunks = items
|
|
454
|
+
.filter((item) => item.sk?.S && item.data?.B)
|
|
455
|
+
.map((item) => ({ sk: item.sk.S, data: Buffer.from(item.data.B) }));
|
|
411
456
|
chunks.sort((left, right) => left.sk.localeCompare(right.sk));
|
|
412
457
|
if (chunks.length !== manifest.chunkCount) {
|
|
413
458
|
throw new Error(`DynamoDB cache entry is missing chunks (${chunks.length}/${manifest.chunkCount})`);
|
|
414
459
|
}
|
|
415
460
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
416
|
-
if (chunks[index]?.sk !== this.chunkSortKey(manifest.version, index)) {
|
|
461
|
+
if (chunks[index]?.sk !== this.chunkSortKey(address, manifest.version, index)) {
|
|
417
462
|
throw new Error(`DynamoDB cache entry has an invalid chunk index at ${index}`);
|
|
418
463
|
}
|
|
419
464
|
}
|
|
420
465
|
return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.storedBytes);
|
|
421
466
|
}
|
|
422
|
-
|
|
467
|
+
/** Every item matching a partition (optionally a sort-key prefix), following pagination. */
|
|
468
|
+
async queryItems(pk, options) {
|
|
469
|
+
const items = [];
|
|
470
|
+
let cursor;
|
|
471
|
+
do {
|
|
472
|
+
const response = await this.client.send(new QueryCommand({
|
|
473
|
+
TableName: this.tableName,
|
|
474
|
+
KeyConditionExpression: options.prefix
|
|
475
|
+
? "#pk = :pk AND begins_with(#sk, :prefix)"
|
|
476
|
+
: "#pk = :pk",
|
|
477
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", ...options.extraNames },
|
|
478
|
+
ExpressionAttributeValues: {
|
|
479
|
+
":pk": { S: pk },
|
|
480
|
+
...(options.prefix ? { ":prefix": { S: options.prefix } } : {}),
|
|
481
|
+
},
|
|
482
|
+
ProjectionExpression: options.projection,
|
|
483
|
+
ExclusiveStartKey: cursor,
|
|
484
|
+
ConsistentRead: false,
|
|
485
|
+
}));
|
|
486
|
+
items.push(...(response.Items ?? []));
|
|
487
|
+
cursor = response.LastEvaluatedKey;
|
|
488
|
+
} while (cursor);
|
|
489
|
+
return items;
|
|
490
|
+
}
|
|
491
|
+
async deleteItems(items) {
|
|
492
|
+
const keys = items.flatMap((item) => item.pk && item.sk ? [{ pk: item.pk, sk: item.sk }] : []);
|
|
493
|
+
await this.batchWrite(keys.map((Key) => ({ DeleteRequest: { Key } })));
|
|
494
|
+
}
|
|
495
|
+
async invalidateManifest(pk, address, version) {
|
|
423
496
|
try {
|
|
424
497
|
await this.client.send(new DeleteItemCommand({
|
|
425
498
|
TableName: this.tableName,
|
|
426
|
-
Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
|
|
499
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
427
500
|
ConditionExpression: "#version = :version",
|
|
428
501
|
ExpressionAttributeNames: { "#version": "version" },
|
|
429
502
|
ExpressionAttributeValues: { ":version": { S: version } },
|
|
@@ -464,6 +537,23 @@ export class LambderDdbCache {
|
|
|
464
537
|
this.memory.set(key, { stored, encoding, uncompressedBytes, expiresAt }, { ttl });
|
|
465
538
|
}
|
|
466
539
|
normalizeKey(key) {
|
|
540
|
+
if (typeof key === "string") {
|
|
541
|
+
const partition = this.normalizePartition(key);
|
|
542
|
+
return { partition, sortKey: null, memoryKey: this.memoryKeyOf(partition, null) };
|
|
543
|
+
}
|
|
544
|
+
if (!key || typeof key !== "object")
|
|
545
|
+
throw new Error("Cache key is required");
|
|
546
|
+
const partition = this.normalizePartition(key.pk);
|
|
547
|
+
const sortKey = key.sk;
|
|
548
|
+
if (typeof sortKey !== "string" || !sortKey.trim())
|
|
549
|
+
throw new Error("Cache sort key is required");
|
|
550
|
+
const encodedBytes = Buffer.byteLength(encodeSortKey(sortKey), "utf8");
|
|
551
|
+
if (encodedBytes > MAX_SORT_KEY_BYTES) {
|
|
552
|
+
throw new Error(`Cache sort key must be at most ${MAX_SORT_KEY_BYTES} UTF-8 bytes once escaped (${encodedBytes})`);
|
|
553
|
+
}
|
|
554
|
+
return { partition, sortKey, memoryKey: this.memoryKeyOf(partition, sortKey) };
|
|
555
|
+
}
|
|
556
|
+
normalizePartition(key) {
|
|
467
557
|
if (typeof key !== "string" || !key.trim())
|
|
468
558
|
throw new Error("Cache key is required");
|
|
469
559
|
if (Buffer.byteLength(key, "utf8") > 8 * 1024) {
|
|
@@ -471,11 +561,41 @@ export class LambderDdbCache {
|
|
|
471
561
|
}
|
|
472
562
|
return key;
|
|
473
563
|
}
|
|
564
|
+
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
565
|
+
memoryKeyOf(partition, sortKey) {
|
|
566
|
+
return `${partition.length}:${partition}#${sortKey ?? ""}`;
|
|
567
|
+
}
|
|
474
568
|
async partitionKey(key) {
|
|
475
569
|
return `${this.keyPrefix}#${this.namespace}#${await sha256(key)}`;
|
|
476
570
|
}
|
|
477
|
-
|
|
478
|
-
|
|
571
|
+
/**
|
|
572
|
+
* One of an entry's item keys. A plain-string entry keeps the bare
|
|
573
|
+
* suffix it has always used; a grouped one nests under its escaped sort
|
|
574
|
+
* key, whose trailing `#` is an unambiguous boundary because an escaped
|
|
575
|
+
* sort key never contains a bare `#`.
|
|
576
|
+
*/
|
|
577
|
+
itemSortKey(address, suffix) {
|
|
578
|
+
return address.sortKey === null ? suffix : `${SORT_KEY_MARKER}${encodeSortKey(address.sortKey)}#${suffix}`;
|
|
579
|
+
}
|
|
580
|
+
/** The prefix covering every item of a grouped entry; null for a plain-string entry, which owns the bare item keys instead. */
|
|
581
|
+
entryItemPrefix(address) {
|
|
582
|
+
return address.sortKey === null ? null : `${SORT_KEY_MARKER}${encodeSortKey(address.sortKey)}#`;
|
|
583
|
+
}
|
|
584
|
+
isManifestSortKey(sk) {
|
|
585
|
+
return sk === META_SORT_KEY || (!!sk && sk.startsWith(SORT_KEY_MARKER) && sk.endsWith(`#${META_SORT_KEY}`));
|
|
586
|
+
}
|
|
587
|
+
chunkSortKey(address, version, index) {
|
|
588
|
+
return this.itemSortKey(address, `${CHUNK_SORT_KEY_PREFIX}${version}#${String(index).padStart(6, "0")}`);
|
|
589
|
+
}
|
|
590
|
+
/** Drop every in-memory copy belonging to one partition. */
|
|
591
|
+
forgetPartition(partition) {
|
|
592
|
+
if (!this.memory)
|
|
593
|
+
return;
|
|
594
|
+
const prefix = this.memoryKeyOf(partition, "");
|
|
595
|
+
for (const key of [...this.memory.keys()]) {
|
|
596
|
+
if (key.startsWith(prefix))
|
|
597
|
+
this.memory.delete(key);
|
|
598
|
+
}
|
|
479
599
|
}
|
|
480
600
|
nowSeconds() {
|
|
481
601
|
return Math.floor(Date.now() / 1000);
|