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 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, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
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)
@@ -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 a session API's options: optional by default,
165
- * required once create() received requireSessionApiGuards, so that an
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 LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
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 (requireSessionApiGuards): an API the session alone authorizes declares the named no-op session guard. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
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>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
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
- } & LambderSessionGuardsField<_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>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
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
  };
@@ -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
- if (this.requireSessionApiGuards && !options.guards) {
120
- throw new Error("Lambder: requireSessionApiGuards needs a guards map at creation for session APIs to declare from.");
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
- if (mode === "session" && this.requireSessionApiGuards && options.guards === undefined) {
219
- throw new Error(`Lambder: session API "${name}" declares no guards, and requireSessionApiGuards is on. ` +
220
- `Declare the guard that authorizes it, or the named no-op guard that marks the session itself as the whole authorization.`);
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
- * The per-API `guards` option: one paramless guard name, an ordered list of
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
- for (const { name } of toGuardEntries(guardsOption)) {
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
- input: In;
35
- output: Out;
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: string): Promise<T | undefined>;
59
- has(key: string): Promise<boolean>;
60
- set<T>(key: string, value: T, options?: LambderDdbCacheSetOptions): Promise<void>;
61
- delete(key: string): Promise<boolean>;
62
- getOrSet<T>(key: string, factory: () => Promise<T>, options?: LambderDdbCacheGetOrSetOptions): Promise<T>;
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
- const normalizedKey = this.normalizeKey(key);
98
- const cached = this.memory?.get(normalizedKey);
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(normalizedKey);
110
- const pk = await this.partitionKey(normalizedKey);
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(normalizedKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
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 normalizedKey = this.normalizeKey(key);
135
- const cached = this.memory?.get(normalizedKey);
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(normalizedKey);
141
- const manifest = await this.readManifest(await this.partitionKey(normalizedKey));
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
- const normalizedKey = this.normalizeKey(key);
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(normalizedKey);
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(normalizedKey, stored, encoding, input.length, expiresAt);
224
+ this.remember(address.memoryKey, stored, encoding, input.length, expiresAt);
198
225
  }
199
226
  async delete(key) {
200
- const normalizedKey = this.normalizeKey(key);
201
- const pk = await this.partitionKey(normalizedKey);
202
- this.memory?.delete(normalizedKey);
203
- const keys = [];
204
- let cursor;
205
- do {
206
- const response = await this.client.send(new QueryCommand({
207
- TableName: this.tableName,
208
- KeyConditionExpression: "#pk = :pk",
209
- ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" },
210
- ExpressionAttributeValues: { ":pk": { S: pk } },
211
- ProjectionExpression: "#pk, #sk",
212
- ExclusiveStartKey: cursor,
213
- }));
214
- for (const item of response.Items ?? []) {
215
- if (item.pk && item.sk)
216
- keys.push({ pk: item.pk, sk: item.sk });
217
- }
218
- cursor = response.LastEvaluatedKey;
219
- } while (cursor);
220
- await this.batchWrite(keys.map((Key) => ({ DeleteRequest: { Key } })));
221
- return keys.length > 0;
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 normalizedKey = this.normalizeKey(key);
225
- const current = this.inFlight.get(normalizedKey);
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(normalizedKey, factory, options).finally(() => {
229
- this.inFlight.delete(normalizedKey);
284
+ const fill = this.getOrSetFailOpen(address, factory, options).finally(() => {
285
+ this.inFlight.delete(address.memoryKey);
230
286
  });
231
- this.inFlight.set(normalizedKey, fill);
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(key, factory, options) {
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.get(key);
306
+ const existing = await this.getByAddress(address);
251
307
  if (existing !== undefined)
252
308
  return existing;
253
- return await this.fill(key, trackedFactory, options);
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 ${key}`, error);
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(key, factory, options) {
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(key);
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.set(key, value, { ttlSeconds: options.ttlSeconds });
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.get(key);
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.set(key, loaded, { ttlSeconds: options.ttlSeconds });
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 = `chunk#${manifest.version}#`;
392
- const chunks = [];
393
- let cursor;
394
- do {
395
- const response = await this.client.send(new QueryCommand({
396
- TableName: this.tableName,
397
- KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :prefix)",
398
- ExpressionAttributeValues: { ":pk": { S: pk }, ":prefix": { S: prefix } },
399
- ProjectionExpression: "#sk, #data",
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
- async invalidateManifest(pk, version) {
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
- chunkSortKey(version, index) {
478
- return `chunk#${version}#${String(index).padStart(6, "0")}`;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.7.3",
3
+ "version": "4.9.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",