lambder 4.7.3 → 4.8.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,11 @@
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.8:**
6
+
7
+ - **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.
8
+ - **`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.
9
+
5
10
  **New in 4.7:**
6
11
 
7
12
  - **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 +180,19 @@ export type ApiContractType = typeof lambder.ApiContract;
175
180
  export const handler = lambder.getHandler();
176
181
  ```
177
182
 
183
+ 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:
184
+
185
+ ```typescript
186
+ type PermissionNeededBy<K extends keyof ApiContractType> =
187
+ ApiContractType[K] extends { guards: { orgPermission: infer N } } ? N : never;
188
+
189
+ const NEEDS = {
190
+ getUser: "USERS.MANAGE",
191
+ } as const satisfies { [K in keyof ApiContractType]?: PermissionNeededBy<K> };
192
+ ```
193
+
194
+ 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.
195
+
178
196
  ### Adding Routes
179
197
 
180
198
  ```typescript
@@ -783,7 +801,7 @@ Also enforced at registration: **duplicate API names throw** (dispatch is first-
783
801
 
784
802
  ### DynamoDB Cache (LambderDdbCache)
785
803
 
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).**
804
+ 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
805
 
788
806
  ```typescript
789
807
  import { LambderDdbCache } from "lambder";
@@ -799,6 +817,13 @@ const city = await cache.getOrSet(`city:${slug}`, async () => fetchCityFromDb(sl
799
817
  ttlSeconds: 7 * 24 * 3600,
800
818
  });
801
819
  // Also: cache.get(key), cache.set(key, value, { ttlSeconds }), cache.has(key), cache.delete(key)
820
+
821
+ // A key can also be a { pk, sk } pair, which groups related entries under one
822
+ // partition so the whole group can be invalidated without listing its members:
823
+ const window = { pk: `division:${divisionId}`, sk: `${from}:${to}` };
824
+ await cache.getOrSet(window, () => loadDivision(divisionId, from, to));
825
+ await cache.deletePartition(`division:${divisionId}`); // every cached window of it
826
+ await cache.listSortKeys(`division:${divisionId}`); // ["1700:1800", "1700:1900"]
802
827
  ```
803
828
 
804
829
  ### Typed Translations (createLambderI18n)
@@ -288,7 +288,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
288
288
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
289
289
  ttlSeconds?: number;
290
290
  }) : 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>;
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>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
292
292
  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
293
  input: TInput;
294
294
  output: TOutput;
@@ -299,7 +299,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
299
299
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
300
300
  ttlSeconds?: number;
301
301
  }) : 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>;
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>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
303
303
  /**
304
304
  * Fetch the session or short-circuit the request: API calls get the
305
305
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
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";
@@ -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.8.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",