lambder 10.1.1 → 10.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,35 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [10.2.1] - 2026-09-29
13
+
14
+ A minor for an app that keeps its cache in its own storage. It writes a few
15
+ small storage methods and gets a `LambderCache` that keeps the same rules as
16
+ the DynamoDB cache and its memory twin, rather than rewriting those rules and
17
+ drifting from them. Nothing existing changes.
18
+
19
+ ### Added
20
+
21
+ - **`LambderStorageBackedCache`: a `LambderCache` over storage the app
22
+ supplies.** `new LambderStorageBackedCache({ storage, defaultTtlSeconds?,
23
+ maxValueBytes?, now? })` takes a `LambderCacheStorage`, five methods over
24
+ the app's own table (`read`, `write`, `delete`, `deletePartition`,
25
+ `listSortKeys`), and brings every rule itself: the same key checks, the
26
+ value stored as its JSON text and handed back as a fresh parse, the same
27
+ TTL and expiry second, only live entries counted by `delete` and
28
+ `deletePartition`, sort keys listed in the table's order whatever order the
29
+ storage finds them in, and `getOrSet`'s single-flight in one process, its
30
+ fail-open when the storage throws, and a `set`, `delete` or
31
+ `deletePartition` of a key winning over a fill in progress. The conformance
32
+ suite that drives `LambderDdbCache` and `LambderMemoryCache` drives it too.
33
+ It holds no fill lease across processes and no memory layer. The storage
34
+ receives a checked `LambderCacheAddress` (`partition`, `sortKey`, null for a
35
+ plain key, and `memoryKey`) and stores a `LambderCacheStoredEntry` (`json`,
36
+ `expiresAt` in epoch seconds). Exported with
37
+ `LambderStorageBackedCacheOptions`, `LambderCacheStorage`,
38
+ `LambderCacheStoredEntry` and `LambderCacheAddress`; docs/ddb-cache.md
39
+ shows a storage over a SQL table.
40
+
12
41
  ## [10.1.1] - 2026-09-28
13
42
 
14
43
  A minor for an app made of parts. Each part can declare its own guards and
package/README.md CHANGED
@@ -191,7 +191,7 @@ framework:
191
191
  | --- | --- | --- |
192
192
  | `html` / `xml` tags + `LambderTemplatingEngine` | [Templating](./docs/templating.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
193
193
  | `createLambderI18n` | [Translations](./docs/i18n.md) | Typed translations with enforced/optional languages, component-level extension, auto language detection and on-demand language loading (isomorphic) |
194
- | `LambderDdbCache` / `LambderMemoryCache` | [DynamoDB cache](./docs/ddb-cache.md) | JSON cache behind one `LambderCache` interface: DynamoDB-backed and compressed, with lease-based single-fill and grouped keys (server-only), or in memory for tests |
194
+ | `LambderDdbCache` / `LambderMemoryCache` / `LambderStorageBackedCache` | [DynamoDB cache](./docs/ddb-cache.md) | JSON cache behind one `LambderCache` interface: DynamoDB-backed and compressed, with lease-based single-fill and grouped keys (server-only), in memory for tests, or over an app's own storage (a SQL table, Redis) with the same rules |
195
195
  | `LambderDdbRateLimiter` / `LambderMemoryRateLimiter` | [Rate limiter](./docs/ddb-rate-limiter.md) | Fixed-window rate limiter, atomic per window, in DynamoDB (server-only) or in memory |
196
196
  | `LambderDdbIdempotencyStore` / `LambderMemoryIdempotencyStore` | [Idempotency store](./docs/ddb-idempotency.md) | Idempotency records with owner-checked claims, in DynamoDB (compressed replays, server-only) or in memory |
197
197
  | `LambderMockApp` | [The mock runtime](./docs/mock.md) | The typed contract served from mock handlers over the real API pipeline, with failure injection, sessions and a call log (isomorphic) |
package/dist/index.d.ts CHANGED
@@ -108,6 +108,9 @@ export { LambderDdbCache } from "./stores/LambderDdbCache.js";
108
108
  export type { LambderDdbCacheOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
109
109
  export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
110
110
  export type { LambderMemoryCacheOptions } from "./stores/LambderMemoryCache.js";
111
+ export { LambderStorageBackedCache } from "./stores/LambderStorageBackedCache.js";
112
+ export type { LambderStorageBackedCacheOptions, LambderCacheStorage, LambderCacheStoredEntry, } from "./stores/LambderStorageBackedCache.js";
113
+ export type { LambderCacheAddress } from "./stores/LambderCacheKeys.js";
111
114
  export { RATE_LIMIT_WINDOWS } from "./shared/contracts/LambderRateLimiter.js";
112
115
  export type { LambderRateLimiter, LambderRateLimitWindow, LambderRateLimitPolicy, LambderRateLimitExceeded, LambderRateLimitResult, } from "./shared/contracts/LambderRateLimiter.js";
113
116
  export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ export { LambderSessionDataRefreshError, LambderSessionReadError } from "./sessi
74
74
  export { LambderSessionNotFoundError, LambderSessionAmbiguousError } from "./session/LambderSessionController.js";
75
75
  export { LambderDdbCache } from "./stores/LambderDdbCache.js";
76
76
  export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
77
+ export { LambderStorageBackedCache } from "./stores/LambderStorageBackedCache.js";
77
78
  // Fixed-window rate limiting: the shared vocabulary, the DynamoDB limiter and the in-memory one
78
79
  export { RATE_LIMIT_WINDOWS } from "./shared/contracts/LambderRateLimiter.js";
79
80
  export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
@@ -29,10 +29,11 @@ export interface LambderCacheListOptions {
29
29
  limit?: number;
30
30
  }
31
31
  /**
32
- * What an app asks of a cache. LambderDdbCache and LambderMemoryCache
33
- * implement it with the same rules (the same key limits, the same JSON round
34
- * trip, the same expiry), so code written against this interface behaves the
35
- * same over either.
32
+ * What an app asks of a cache. LambderDdbCache, LambderMemoryCache and
33
+ * LambderStorageBackedCache (over an app's own storage) implement it with the
34
+ * same rules (the same key limits, the same JSON round trip, the same
35
+ * expiry), so code written against this interface behaves the same over any
36
+ * of them.
36
37
  *
37
38
  * Values are JSON: what `set` is handed is stored as its JSON text and `get`
38
39
  * hands back a parse of it, never the object that was stored.
@@ -26,8 +26,9 @@ export type LambderCacheLoad<T> = (store: (value: T) => Promise<T>) => Promise<T
26
26
  * write starts a load of its own instead of joining the old one. The old
27
27
  * fill's callers get its value uncached.
28
28
  *
29
- * LambderDdbCache and LambderMemoryCache each supply only their read-then-fill
30
- * and hold one filler, so the two cannot drift on any of the above.
29
+ * LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache each
30
+ * supply only their read-then-fill and hold one filler, so they cannot drift
31
+ * on any of the above.
31
32
  */
32
33
  export declare class LambderCacheFiller {
33
34
  private readonly inFlight;
@@ -20,8 +20,9 @@ import { asStoredJson } from "./LambderCacheValues.js";
20
20
  * write starts a load of its own instead of joining the old one. The old
21
21
  * fill's callers get its value uncached.
22
22
  *
23
- * LambderDdbCache and LambderMemoryCache each supply only their read-then-fill
24
- * and hold one filler, so the two cannot drift on any of the above.
23
+ * LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache each
24
+ * supply only their read-then-fill and hold one filler, so they cannot drift
25
+ * on any of the above.
25
26
  */
26
27
  export class LambderCacheFiller {
27
28
  inFlight = new Map();
@@ -1,9 +1,13 @@
1
1
  import type { LambderCacheKey } from "../shared/contracts/LambderCache.js";
2
- /** One entry's address, normalized: `sortKey` is null for a plain string key. */
2
+ /**
3
+ * One entry's address, normalized: `sortKey` is null for a plain string key.
4
+ * A sort key is never empty (a blank one is refused), so a storage whose
5
+ * column cannot hold null can keep a plain key's entry under "".
6
+ */
3
7
  export interface LambderCacheAddress {
4
8
  partition: string;
5
9
  sortKey: string | null;
6
- /** Unambiguous key for an in-memory layer and an in-flight map. */
10
+ /** One string that tells every address apart: the key of an in-memory layer, an in-flight map, or a storage keyed by one string. */
7
11
  memoryKey: string;
8
12
  }
9
13
  /**
@@ -13,11 +17,18 @@ export interface LambderCacheAddress {
13
17
  * `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
14
18
  * per-character, so a prefix of the raw key stays a prefix of the encoded
15
19
  * one; only the sort ORDER of keys that contain `#` or `~` shifts, since both
16
- * encode into the `~` range. The memory cache sorts by the same encoding, so
17
- * listSortKeys answers in the same order over either store.
20
+ * encode into the `~` range. The other caches sort by the same encoding (see
21
+ * compareCacheSortKeys), so listSortKeys answers in the same order over any.
18
22
  */
19
23
  export declare const encodeCacheSortKey: (value: string) => string;
20
24
  export declare const decodeCacheSortKey: (value: string) => string;
25
+ /**
26
+ * Orders two sort keys as LambderDdbCache lists them, for the caches that
27
+ * sort a listing themselves: by the whole item sort key the table ranges
28
+ * over, which carries a "#" after the encoded key. So "New York City" sorts
29
+ * before "New York" (" " is below "#"), over every cache alike.
30
+ */
31
+ export declare const compareCacheSortKeys: (first: string, second: string) => number;
21
32
  /** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
22
33
  export declare const cacheMemoryKeyOf: (partition: string, sortKey: string | null) => string;
23
34
  /** A partition as the caches accept it, or a throw naming what is wrong with it. */
@@ -1,10 +1,11 @@
1
1
  /*
2
- * How a cache key is checked and spelled, for every cache here.
2
+ * How a cache key is checked, spelled and ordered, for every cache here.
3
3
  *
4
- * LambderDdbCache and LambderMemoryCache take the same keys, and a key one of
5
- * them refuses has to be refused by the other: a test that runs over the
6
- * memory cache must not accept a key production then throws on. So the rules
7
- * are written once, here, and both stores normalize through them.
4
+ * LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache take the
5
+ * same keys, and a key one of them refuses has to be refused by the others: a
6
+ * test that runs over the memory cache must not accept a key production then
7
+ * throws on. So the rules are written once, here, and every cache normalizes
8
+ * through them.
8
9
  */
9
10
  /** Longest partition accepted, in UTF-8 bytes. */
10
11
  const MAX_PARTITION_BYTES = 8 * 1024;
@@ -18,11 +19,29 @@ const utf8Bytes = (value) => new TextEncoder().encode(value).length;
18
19
  * `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
19
20
  * per-character, so a prefix of the raw key stays a prefix of the encoded
20
21
  * one; only the sort ORDER of keys that contain `#` or `~` shifts, since both
21
- * encode into the `~` range. The memory cache sorts by the same encoding, so
22
- * listSortKeys answers in the same order over either store.
22
+ * encode into the `~` range. The other caches sort by the same encoding (see
23
+ * compareCacheSortKeys), so listSortKeys answers in the same order over any.
23
24
  */
24
25
  export const encodeCacheSortKey = (value) => value.replace(/~/g, "~0").replace(/#/g, "~1");
25
26
  export const decodeCacheSortKey = (value) => value.replace(/~([01])/g, (_match, code) => code === "0" ? "~" : "#");
27
+ /** DynamoDB orders string range keys by their UTF-8 bytes, which is not JavaScript's UTF-16 order for every character. */
28
+ const compareUtf8 = (first, second) => {
29
+ const a = new TextEncoder().encode(first);
30
+ const b = new TextEncoder().encode(second);
31
+ const length = Math.min(a.length, b.length);
32
+ for (let index = 0; index < length; index += 1) {
33
+ if (a[index] !== b[index])
34
+ return a[index] - b[index];
35
+ }
36
+ return a.length - b.length;
37
+ };
38
+ /**
39
+ * Orders two sort keys as LambderDdbCache lists them, for the caches that
40
+ * sort a listing themselves: by the whole item sort key the table ranges
41
+ * over, which carries a "#" after the encoded key. So "New York City" sorts
42
+ * before "New York" (" " is below "#"), over every cache alike.
43
+ */
44
+ export const compareCacheSortKeys = (first, second) => compareUtf8(`${encodeCacheSortKey(first)}#`, `${encodeCacheSortKey(second)}#`);
26
45
  /** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
27
46
  export const cacheMemoryKeyOf = (partition, sortKey) => `${partition.length}:${partition}#${sortKey ?? ""}`;
28
47
  /** A partition as the caches accept it, or a throw naming what is wrong with it. */
@@ -34,9 +34,9 @@ export declare const resolveCacheTtlSeconds: (ttlSeconds: number | undefined, de
34
34
  * cache failing: every call would log, hand the loader's value back
35
35
  * uncached, and caching would be off without the caller ever seeing why.
36
36
  *
37
- * The lease options are LambderDdbCache's. The memory cache holds no lease
38
- * but checks them all the same, so an options object that the table refuses
39
- * is refused by its twin too.
37
+ * The lease options are LambderDdbCache's. The other caches hold no lease
38
+ * but check them all the same, so an options object that the table refuses
39
+ * is refused by every cache.
40
40
  */
41
41
  export declare const resolveGetOrSetOptions: (options: {
42
42
  ttlSeconds?: number;
@@ -3,10 +3,11 @@ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
3
3
  * What a cache value has to be, and how a cache call's options are read, for
4
4
  * every cache here.
5
5
  *
6
- * LambderDdbCache and LambderMemoryCache refuse the same values and the same
7
- * options for the reason they refuse the same keys (see LambderCacheKeys): a
8
- * test that runs over the memory cache must not accept what production then
9
- * throws on. So the rules are written once, here, and both caches call them.
6
+ * LambderDdbCache, LambderMemoryCache and LambderStorageBackedCache refuse the
7
+ * same values and the same options for the reason they refuse the same keys
8
+ * (see LambderCacheKeys): a test that runs over the memory cache must not
9
+ * accept what production then throws on. So the rules are written once, here,
10
+ * and every cache calls them.
10
11
  */
11
12
  /** How long an entry lives when neither the call nor the cache names a TTL: one year. */
12
13
  export const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
@@ -53,9 +54,9 @@ export const resolveCacheTtlSeconds = (ttlSeconds, defaultTtlSeconds) => assertP
53
54
  * cache failing: every call would log, hand the loader's value back
54
55
  * uncached, and caching would be off without the caller ever seeing why.
55
56
  *
56
- * The lease options are LambderDdbCache's. The memory cache holds no lease
57
- * but checks them all the same, so an options object that the table refuses
58
- * is refused by its twin too.
57
+ * The lease options are LambderDdbCache's. The other caches hold no lease
58
+ * but check them all the same, so an options object that the table refuses
59
+ * is refused by every cache.
59
60
  */
60
61
  export const resolveGetOrSetOptions = (options, defaultTtlSeconds) => {
61
62
  const leaseSeconds = assertPositiveInteger(options.leaseSeconds ?? DEFAULT_LEASE_SECONDS, "leaseSeconds");
@@ -80,7 +80,7 @@ export declare class LambderDdbCache implements LambderCache {
80
80
  private readonly compression;
81
81
  private readonly maxValueBytes;
82
82
  private readonly memory;
83
- /** getOrSet's single-flight and fail-open, shared with LambderMemoryCache (see LambderCacheFiller). */
83
+ /** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
84
84
  private readonly filler;
85
85
  private readonly now;
86
86
  /** This instance's own writes, as its reads and its memory layer need to know them (see LocalWriteLedger). */
@@ -202,7 +202,7 @@ export class LambderDdbCache {
202
202
  compression;
203
203
  maxValueBytes;
204
204
  memory;
205
- /** getOrSet's single-flight and fail-open, shared with LambderMemoryCache (see LambderCacheFiller). */
205
+ /** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
206
206
  filler;
207
207
  now;
208
208
  /** This instance's own writes, as its reads and its memory layer need to know them (see LocalWriteLedger). */
@@ -16,7 +16,7 @@ export interface LambderMemoryCacheOptions {
16
16
  * refuses, stores a value's JSON text and hands back a fresh parse of it,
17
17
  * expires entries on the same TTL, and lists sort keys in the same order.
18
18
  *
19
- * getOrSet runs through the LambderCacheFiller both caches hold: concurrent
19
+ * getOrSet runs through the LambderCacheFiller every cache holds: concurrent
20
20
  * calls for one key share a load, every call (the filling one included)
21
21
  * answers the stored JSON parsed, a loader's undefined comes back uncached,
22
22
  * and a set, delete or deletePartition of the key while the loader runs
@@ -29,7 +29,7 @@ export interface LambderMemoryCacheOptions {
29
29
  */
30
30
  export declare class LambderMemoryCache implements LambderCache {
31
31
  private readonly entries;
32
- /** getOrSet's single-flight and fail-open, shared with LambderDdbCache (see LambderCacheFiller). */
32
+ /** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
33
33
  private readonly filler;
34
34
  private readonly defaultTtlSeconds;
35
35
  private readonly maxValueBytes;
@@ -1,19 +1,8 @@
1
1
  import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
2
2
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
3
- import { cacheMemoryKeyOf, encodeCacheSortKey, normalizeCacheKey, normalizeCachePartition } from "./LambderCacheKeys.js";
3
+ import { cacheMemoryKeyOf, compareCacheSortKeys, normalizeCacheKey, normalizeCachePartition } from "./LambderCacheKeys.js";
4
4
  import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
5
5
  import { LambderCacheFiller } from "./LambderCacheFiller.js";
6
- /** DynamoDB orders string range keys by their UTF-8 bytes, which is not JavaScript's UTF-16 order for every character. */
7
- const compareUtf8 = (first, second) => {
8
- const a = new TextEncoder().encode(first);
9
- const b = new TextEncoder().encode(second);
10
- const length = Math.min(a.length, b.length);
11
- for (let index = 0; index < length; index += 1) {
12
- if (a[index] !== b[index])
13
- return a[index] - b[index];
14
- }
15
- return a.length - b.length;
16
- };
17
6
  /**
18
7
  * LambderDdbCache's twin, held in memory: the same LambderCache interface and
19
8
  * the same rules, so code written against the interface can be tested
@@ -21,7 +10,7 @@ const compareUtf8 = (first, second) => {
21
10
  * refuses, stores a value's JSON text and hands back a fresh parse of it,
22
11
  * expires entries on the same TTL, and lists sort keys in the same order.
23
12
  *
24
- * getOrSet runs through the LambderCacheFiller both caches hold: concurrent
13
+ * getOrSet runs through the LambderCacheFiller every cache holds: concurrent
25
14
  * calls for one key share a load, every call (the filling one included)
26
15
  * answers the stored JSON parsed, a loader's undefined comes back uncached,
27
16
  * and a set, delete or deletePartition of the key while the loader runs
@@ -34,7 +23,7 @@ const compareUtf8 = (first, second) => {
34
23
  */
35
24
  export class LambderMemoryCache {
36
25
  entries;
37
- /** getOrSet's single-flight and fail-open, shared with LambderDdbCache (see LambderCacheFiller). */
26
+ /** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
38
27
  filler = new LambderCacheFiller("Memory cache failed open");
39
28
  defaultTtlSeconds;
40
29
  maxValueBytes;
@@ -83,11 +72,7 @@ export class LambderMemoryCache {
83
72
  const limit = options.limit === undefined ? undefined : assertPositiveInteger(options.limit, "limit");
84
73
  const sortKeys = this.entries.values()
85
74
  .flatMap(({ address }) => address.partition === normalized && address.sortKey !== null && address.sortKey.startsWith(prefix) ? [address.sortKey] : [])
86
- // Ordered as the table orders the items it lists: by the whole
87
- // item sort key, which carries a "#" after the encoded key. So
88
- // "New York City" sorts before "New York" there (" " is below
89
- // "#"), and here too.
90
- .sort((first, second) => compareUtf8(`${encodeCacheSortKey(first)}#`, `${encodeCacheSortKey(second)}#`));
75
+ .sort(compareCacheSortKeys);
91
76
  return limit === undefined ? sortKeys : sortKeys.slice(0, limit);
92
77
  }
93
78
  async getOrSet(key, loader, options = {}) {
@@ -0,0 +1,95 @@
1
+ import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
2
+ import { type LambderCacheAddress } from "./LambderCacheKeys.js";
3
+ /** One entry as a storage keeps it: the value's JSON text and the second it stops being live. */
4
+ export interface LambderCacheStoredEntry {
5
+ json: string;
6
+ /** Epoch seconds: the entry is live while this is greater than the current second, so it expires at this second itself. */
7
+ expiresAt: number;
8
+ }
9
+ /**
10
+ * Where a LambderStorageBackedCache keeps its entries: a SQL table, a Redis
11
+ * database, anything that can hold a JSON text and an expiry under an
12
+ * address. Only reading and writing are asked of it; the key checks, the
13
+ * JSON round trip, the TTL, the listing order and getOrSet's single-flight
14
+ * are the cache's, so no storage can drift from the rules the other caches
15
+ * keep.
16
+ *
17
+ * An entry is live at `nowSeconds` while its `expiresAt` is greater than
18
+ * `nowSeconds`. The cache hands `nowSeconds` to the methods that answer
19
+ * about live entries, so a storage can filter in its own query. It never
20
+ * removes an expired entry it is not asked to, so a storage purges them on
21
+ * its own schedule (a periodic delete, a native TTL) or grows without bound.
22
+ *
23
+ * A plain string key and the `{ pk, sk }` keys of one partition share that
24
+ * partition: `address.sortKey` is null for the plain one.
25
+ */
26
+ export interface LambderCacheStorage {
27
+ /** The entry at this address, expired or not: the cache decides what is live. Null when there is none. */
28
+ read(address: LambderCacheAddress): Promise<LambderCacheStoredEntry | null>;
29
+ /** Creates the entry at this address, or replaces the one there. */
30
+ write(address: LambderCacheAddress, entry: LambderCacheStoredEntry): Promise<void>;
31
+ /** Removes the entry at this address, live or not; true when it was live at `nowSeconds`. */
32
+ delete(address: LambderCacheAddress, nowSeconds: number): Promise<boolean>;
33
+ /** Removes every entry under the partition, plain key and sort keys alike, and answers how many of them were live at `nowSeconds`. */
34
+ deletePartition(partition: string, nowSeconds: number): Promise<number>;
35
+ /**
36
+ * Every sort key under the partition that starts with `prefix` and is
37
+ * live at `nowSeconds`, in any order: the cache sorts the list and cuts
38
+ * it to the caller's limit. A plain key's entry has no sort key and is
39
+ * never listed.
40
+ */
41
+ listSortKeys(partition: string, prefix: string, nowSeconds: number): Promise<string[]>;
42
+ }
43
+ export interface LambderStorageBackedCacheOptions {
44
+ /** Where the entries live. */
45
+ storage: LambderCacheStorage;
46
+ /** Default: one year, as the other caches'. */
47
+ defaultTtlSeconds?: number;
48
+ /** Largest value accepted, in UTF-8 bytes of its JSON. Default: 32 MiB, as the other caches'. */
49
+ maxValueBytes?: number;
50
+ /** The clock entries expire against, in epoch milliseconds, so a test can cross a TTL without waiting. */
51
+ now?: () => number;
52
+ }
53
+ /**
54
+ * A LambderCache over storage the app supplies (see LambderCacheStorage),
55
+ * with every rule LambderMemoryCache keeps: it refuses the keys and values
56
+ * the other caches refuse, stores a value's JSON text and hands back a fresh
57
+ * parse of it, expires entries on the same TTL, counts only live entries in
58
+ * what delete and deletePartition answer, and lists sort keys in the table's
59
+ * order whatever order the storage finds them in.
60
+ *
61
+ * getOrSet runs through the LambderCacheFiller every cache holds: concurrent
62
+ * calls for one key in this process share a load, every call answers the
63
+ * stored JSON parsed, a loader's undefined comes back uncached, a set,
64
+ * delete or deletePartition of the key while the loader runs keeps the fill
65
+ * from storing over it, and a storage that fails (a read, a write) hands the
66
+ * loader's value back uncached rather than failing the caller. A fill whose
67
+ * write is already on its way to the storage when a write of the same key
68
+ * arrives races it there, as two overlapping sets do.
69
+ *
70
+ * It holds no fill lease across processes: two processes missing one key
71
+ * each run the loader, and the last write stands. It keeps nothing in
72
+ * memory either, so every read asks the storage.
73
+ */
74
+ export declare class LambderStorageBackedCache implements LambderCache {
75
+ private readonly storage;
76
+ /** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
77
+ private readonly filler;
78
+ private readonly defaultTtlSeconds;
79
+ private readonly maxValueBytes;
80
+ private readonly now;
81
+ constructor(options: LambderStorageBackedCacheOptions);
82
+ get<T>(key: LambderCacheKey): Promise<T | undefined>;
83
+ has(key: LambderCacheKey): Promise<boolean>;
84
+ set<T>(key: LambderCacheKey, value: T, options?: LambderCacheSetOptions): Promise<void>;
85
+ delete(key: LambderCacheKey): Promise<boolean>;
86
+ deletePartition(partition: string): Promise<number>;
87
+ listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
88
+ getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheSetOptions): Promise<T>;
89
+ /** Whole seconds, as entries expire in every cache here, so a TTL ends on the same second as the memory cache's. */
90
+ private nowSeconds;
91
+ /** The entry at the address while it is live; the storage may still hold one past its expiry. */
92
+ private readLive;
93
+ /** Stores the value and hands back what was stored, the parse of its JSON. */
94
+ private setByAddress;
95
+ }
@@ -0,0 +1,97 @@
1
+ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
2
+ import { cacheMemoryKeyOf, compareCacheSortKeys, normalizeCacheKey, normalizeCachePartition } from "./LambderCacheKeys.js";
3
+ import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
4
+ import { LambderCacheFiller } from "./LambderCacheFiller.js";
5
+ /**
6
+ * A LambderCache over storage the app supplies (see LambderCacheStorage),
7
+ * with every rule LambderMemoryCache keeps: it refuses the keys and values
8
+ * the other caches refuse, stores a value's JSON text and hands back a fresh
9
+ * parse of it, expires entries on the same TTL, counts only live entries in
10
+ * what delete and deletePartition answer, and lists sort keys in the table's
11
+ * order whatever order the storage finds them in.
12
+ *
13
+ * getOrSet runs through the LambderCacheFiller every cache holds: concurrent
14
+ * calls for one key in this process share a load, every call answers the
15
+ * stored JSON parsed, a loader's undefined comes back uncached, a set,
16
+ * delete or deletePartition of the key while the loader runs keeps the fill
17
+ * from storing over it, and a storage that fails (a read, a write) hands the
18
+ * loader's value back uncached rather than failing the caller. A fill whose
19
+ * write is already on its way to the storage when a write of the same key
20
+ * arrives races it there, as two overlapping sets do.
21
+ *
22
+ * It holds no fill lease across processes: two processes missing one key
23
+ * each run the loader, and the last write stands. It keeps nothing in
24
+ * memory either, so every read asks the storage.
25
+ */
26
+ export class LambderStorageBackedCache {
27
+ storage;
28
+ /** getOrSet's single-flight and fail-open, shared with the other caches (see LambderCacheFiller). */
29
+ filler = new LambderCacheFiller("Storage-backed cache failed open");
30
+ defaultTtlSeconds;
31
+ maxValueBytes;
32
+ now;
33
+ constructor(options) {
34
+ // Here rather than at first use, where getOrSet would fail open over
35
+ // it on every call and caching would be off without a caller seeing why.
36
+ if (!options.storage)
37
+ throw new Error("storage is required");
38
+ this.storage = options.storage;
39
+ this.now = options.now ?? (() => Date.now());
40
+ this.defaultTtlSeconds = assertPositiveInteger(options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS, "defaultTtlSeconds");
41
+ this.maxValueBytes = assertPositiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
42
+ }
43
+ async get(key) {
44
+ const entry = await this.readLive(normalizeCacheKey(key));
45
+ return entry ? JSON.parse(entry.json) : undefined;
46
+ }
47
+ async has(key) {
48
+ return await this.readLive(normalizeCacheKey(key)) !== null;
49
+ }
50
+ async set(key, value, options = {}) {
51
+ const address = normalizeCacheKey(key);
52
+ const ttlSeconds = resolveCacheTtlSeconds(options.ttlSeconds, this.defaultTtlSeconds);
53
+ this.filler.supersedeFill(address.memoryKey);
54
+ await this.setByAddress(address, value, ttlSeconds);
55
+ }
56
+ async delete(key) {
57
+ const address = normalizeCacheKey(key);
58
+ this.filler.supersedeFill(address.memoryKey);
59
+ return await this.storage.delete(address, this.nowSeconds());
60
+ }
61
+ async deletePartition(partition) {
62
+ const normalized = normalizeCachePartition(partition);
63
+ this.filler.supersedeFillsWithPrefix(cacheMemoryKeyOf(normalized, ""));
64
+ return await this.storage.deletePartition(normalized, this.nowSeconds());
65
+ }
66
+ async listSortKeys(partition, options = {}) {
67
+ const normalized = normalizeCachePartition(partition);
68
+ const prefix = options.prefix ?? "";
69
+ const limit = options.limit === undefined ? undefined : assertPositiveInteger(options.limit, "limit");
70
+ // A copy, since the storage may hand back a list it holds on to.
71
+ const sortKeys = [...await this.storage.listSortKeys(normalized, prefix, this.nowSeconds())].sort(compareCacheSortKeys);
72
+ return limit === undefined ? sortKeys : sortKeys.slice(0, limit);
73
+ }
74
+ async getOrSet(key, loader, options = {}) {
75
+ const address = normalizeCacheKey(key);
76
+ const { ttlSeconds } = resolveGetOrSetOptions(options, this.defaultTtlSeconds);
77
+ return this.filler.getOrSet(address.memoryKey, loader, async (load) => {
78
+ const existing = await this.readLive(address);
79
+ return existing ? JSON.parse(existing.json) : await load(async (value) => await this.setByAddress(address, value, ttlSeconds));
80
+ });
81
+ }
82
+ /** Whole seconds, as entries expire in every cache here, so a TTL ends on the same second as the memory cache's. */
83
+ nowSeconds() {
84
+ return Math.floor(this.now() / 1000);
85
+ }
86
+ /** The entry at the address while it is live; the storage may still hold one past its expiry. */
87
+ async readLive(address) {
88
+ const entry = await this.storage.read(address);
89
+ return entry && entry.expiresAt > this.nowSeconds() ? entry : null;
90
+ }
91
+ /** Stores the value and hands back what was stored, the parse of its JSON. */
92
+ async setByAddress(address, value, ttlSeconds) {
93
+ const { json } = serializeCacheValue(value, this.maxValueBytes);
94
+ await this.storage.write(address, { json, expiresAt: this.nowSeconds() + ttlSeconds });
95
+ return JSON.parse(json);
96
+ }
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "10.1.1",
3
+ "version": "10.2.1",
4
4
  "sideEffects": false,
5
5
  "description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
6
6
  "keywords": [