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 +26 -1
- package/dist/core/Lambder.d.ts +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/shared/LambderApiContract.d.ts +13 -6
- package/dist/stores/LambderDdbCache.d.ts +65 -5
- package/dist/stores/LambderDdbCache.js +209 -89
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,6 +2,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,
|
|
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)
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
export type MergeContract<Old, Name extends string, In, Out, GuardInputs = never, Guards = never> = Old & {
|
|
40
|
+
[K in Name]: ([GuardInputs] extends [never] ? {} : {
|
|
41
|
+
guardInputs: GuardInputs;
|
|
42
|
+
}) & ([Guards] extends [never] ? {} : {
|
|
43
|
+
guards: Guards;
|
|
44
|
+
}) & {
|
|
37
45
|
input: In;
|
|
38
46
|
output: Out;
|
|
39
|
-
guardInputs: GuardInputs;
|
|
40
47
|
};
|
|
41
48
|
};
|
|
@@ -19,6 +19,18 @@ export interface LambderDdbCacheOptions {
|
|
|
19
19
|
memoryMaxBytes?: number;
|
|
20
20
|
client?: DynamoDBClient;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Where a value lives. A plain string addresses one entry, as it always has.
|
|
24
|
+
* `{ pk, sk }` puts the entry in a partition it can share with others, so a
|
|
25
|
+
* group can be listed or dropped in one call: `{ pk: "division:ist-34", sk:
|
|
26
|
+
* "1700:1800" }` keeps every cached window of one division together.
|
|
27
|
+
* Only the `pk` part is hashed into the DynamoDB partition key; the sort key
|
|
28
|
+
* is stored readable, which is what makes prefix queries possible.
|
|
29
|
+
*/
|
|
30
|
+
export type LambderCacheKey = string | {
|
|
31
|
+
pk: string;
|
|
32
|
+
sk: string;
|
|
33
|
+
};
|
|
22
34
|
export interface LambderDdbCacheSetOptions {
|
|
23
35
|
ttlSeconds?: number;
|
|
24
36
|
}
|
|
@@ -26,6 +38,12 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
|
|
|
26
38
|
leaseSeconds?: number;
|
|
27
39
|
waitForFillMs?: number;
|
|
28
40
|
}
|
|
41
|
+
export interface LambderDdbCacheListOptions {
|
|
42
|
+
/** Only sort keys starting with this raw (unescaped) prefix. */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/** Cap on RESULTS, not on items read: the partition (or prefix range) is read either way. */
|
|
45
|
+
limit?: number;
|
|
46
|
+
}
|
|
29
47
|
/**
|
|
30
48
|
* Persistent JSON cache backed by DynamoDB.
|
|
31
49
|
*
|
|
@@ -42,6 +60,13 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
|
|
|
42
60
|
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
43
61
|
* table can be shared with LambderDdbRateLimiter (`RL#`) and
|
|
44
62
|
* LambderDdbIdempotency (`IDEM#`) without key collisions.
|
|
63
|
+
*
|
|
64
|
+
* A key may also be a `{ pk, sk }` pair, which groups entries under one
|
|
65
|
+
* partition so `deletePartition` and `listSortKeys` can work on the group
|
|
66
|
+
* without knowing its members. Plain-string keys keep the exact item layout
|
|
67
|
+
* they have always had (`meta`, `lock`, `chunk#...`), and grouped entries
|
|
68
|
+
* live beside them under `sk#<encoded sort key>#...`, so both forms can
|
|
69
|
+
* share a partition and a live table needs no migration.
|
|
45
70
|
*/
|
|
46
71
|
export declare class LambderDdbCache {
|
|
47
72
|
readonly tableName: string;
|
|
@@ -55,11 +80,28 @@ export declare class LambderDdbCache {
|
|
|
55
80
|
private readonly memory;
|
|
56
81
|
private readonly inFlight;
|
|
57
82
|
constructor(options: LambderDdbCacheOptions);
|
|
58
|
-
get<T>(key:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
get<T>(key: LambderCacheKey): Promise<T | undefined>;
|
|
84
|
+
private getByAddress;
|
|
85
|
+
has(key: LambderCacheKey): Promise<boolean>;
|
|
86
|
+
set<T>(key: LambderCacheKey, value: T, options?: LambderDdbCacheSetOptions): Promise<void>;
|
|
87
|
+
private setByAddress;
|
|
88
|
+
delete(key: LambderCacheKey): Promise<boolean>;
|
|
89
|
+
/**
|
|
90
|
+
* Drop every entry stored under one `pk`, without knowing which sort keys
|
|
91
|
+
* exist: the invalidation a group of related entries is worth grouping
|
|
92
|
+
* for. Returns the number of entries removed. In-memory copies held by
|
|
93
|
+
* OTHER Lambda containers still serve until their own TTL, as they do
|
|
94
|
+
* after a single-entry delete.
|
|
95
|
+
*/
|
|
96
|
+
deletePartition(partition: string): Promise<number>;
|
|
97
|
+
/**
|
|
98
|
+
* The live (unexpired) sort keys stored under one `pk`, in table order.
|
|
99
|
+
* Plain-string entries have no sort key, so they never appear here.
|
|
100
|
+
* Reading a partition whose values are chunked also reads those chunk
|
|
101
|
+
* items, so grouping very large values makes listing more expensive.
|
|
102
|
+
*/
|
|
103
|
+
listSortKeys(partition: string, options?: LambderDdbCacheListOptions): Promise<string[]>;
|
|
104
|
+
getOrSet<T>(key: LambderCacheKey, factory: () => Promise<T>, options?: LambderDdbCacheGetOrSetOptions): Promise<T>;
|
|
63
105
|
/**
|
|
64
106
|
* Cache infrastructure is best-effort for getOrSet: read, lease, or write
|
|
65
107
|
* failures return the loader value. Loader failures still propagate and the
|
|
@@ -71,14 +113,32 @@ export declare class LambderDdbCache {
|
|
|
71
113
|
private releaseLease;
|
|
72
114
|
private readManifest;
|
|
73
115
|
private readChunks;
|
|
116
|
+
/** Every item matching a partition (optionally a sort-key prefix), following pagination. */
|
|
117
|
+
private queryItems;
|
|
118
|
+
private deleteItems;
|
|
74
119
|
private invalidateManifest;
|
|
75
120
|
private batchWrite;
|
|
76
121
|
/** The JSON text of a stored payload. */
|
|
77
122
|
private decode;
|
|
78
123
|
private remember;
|
|
79
124
|
private normalizeKey;
|
|
125
|
+
private normalizePartition;
|
|
126
|
+
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
127
|
+
private memoryKeyOf;
|
|
80
128
|
private partitionKey;
|
|
129
|
+
/**
|
|
130
|
+
* One of an entry's item keys. A plain-string entry keeps the bare
|
|
131
|
+
* suffix it has always used; a grouped one nests under its escaped sort
|
|
132
|
+
* key, whose trailing `#` is an unambiguous boundary because an escaped
|
|
133
|
+
* sort key never contains a bare `#`.
|
|
134
|
+
*/
|
|
135
|
+
private itemSortKey;
|
|
136
|
+
/** The prefix covering every item of a grouped entry; null for a plain-string entry, which owns the bare item keys instead. */
|
|
137
|
+
private entryItemPrefix;
|
|
138
|
+
private isManifestSortKey;
|
|
81
139
|
private chunkSortKey;
|
|
140
|
+
/** Drop every in-memory copy belonging to one partition. */
|
|
141
|
+
private forgetPartition;
|
|
82
142
|
private nowSeconds;
|
|
83
143
|
private isConditionalFailure;
|
|
84
144
|
}
|
|
@@ -10,10 +10,26 @@ const DEFAULT_MAX_VALUE_BYTES = 32 * 1024 * 1024;
|
|
|
10
10
|
const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
|
|
11
11
|
const META_SORT_KEY = "meta";
|
|
12
12
|
const LOCK_SORT_KEY = "lock";
|
|
13
|
+
const CHUNK_SORT_KEY_PREFIX = "chunk#";
|
|
14
|
+
/** Item-key prefix that separates entries addressed with a sort key from plain-key entries sharing the partition. */
|
|
15
|
+
const SORT_KEY_MARKER = "sk#";
|
|
16
|
+
/** Budget for one encoded sort key, leaving room for the marker and the longest item suffix inside DynamoDB's 1024-byte range key limit. */
|
|
17
|
+
const MAX_SORT_KEY_BYTES = 900;
|
|
13
18
|
const BATCH_WRITE_LIMIT = 25;
|
|
14
19
|
const MAX_BATCH_RETRIES = 8;
|
|
15
20
|
/** Every value compressed by default; see the `compression` option. */
|
|
16
21
|
const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
22
|
+
/**
|
|
23
|
+
* `#` separates the store's own item-key segments, so a caller's `#` is
|
|
24
|
+
* escaped rather than refused: `~` becomes `~0` and `#` becomes `~1`. An
|
|
25
|
+
* encoded sort key therefore never contains a bare `#`, which keeps
|
|
26
|
+
* `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
|
|
27
|
+
* per-character, so a prefix of the raw key stays a prefix of the encoded
|
|
28
|
+
* one; only the sort ORDER of keys that contain `#` or `~` shifts, since
|
|
29
|
+
* both encode into the `~` range.
|
|
30
|
+
*/
|
|
31
|
+
const encodeSortKey = (value) => value.replace(/~/g, "~0").replace(/#/g, "~1");
|
|
32
|
+
const decodeSortKey = (value) => value.replace(/~([01])/g, (_match, code) => code === "0" ? "~" : "#");
|
|
17
33
|
// Node builtins are loaded lazily through node-polyfills so this module can
|
|
18
34
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
19
35
|
// breaking; using the cache at runtime still requires Node. Brotli helpers
|
|
@@ -56,6 +72,13 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
56
72
|
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
57
73
|
* table can be shared with LambderDdbRateLimiter (`RL#`) and
|
|
58
74
|
* LambderDdbIdempotency (`IDEM#`) without key collisions.
|
|
75
|
+
*
|
|
76
|
+
* A key may also be a `{ pk, sk }` pair, which groups entries under one
|
|
77
|
+
* partition so `deletePartition` and `listSortKeys` can work on the group
|
|
78
|
+
* without knowing its members. Plain-string keys keep the exact item layout
|
|
79
|
+
* they have always had (`meta`, `lock`, `chunk#...`), and grouped entries
|
|
80
|
+
* live beside them under `sk#<encoded sort key>#...`, so both forms can
|
|
81
|
+
* share a partition and a live table needs no migration.
|
|
59
82
|
*/
|
|
60
83
|
export class LambderDdbCache {
|
|
61
84
|
tableName;
|
|
@@ -94,8 +117,10 @@ export class LambderDdbCache {
|
|
|
94
117
|
this.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
|
|
95
118
|
}
|
|
96
119
|
async get(key) {
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
return await this.getByAddress(this.normalizeKey(key));
|
|
121
|
+
}
|
|
122
|
+
async getByAddress(address) {
|
|
123
|
+
const cached = this.memory?.get(address.memoryKey);
|
|
99
124
|
const nowSeconds = this.nowSeconds();
|
|
100
125
|
if (cached && cached.expiresAt > nowSeconds) {
|
|
101
126
|
try {
|
|
@@ -106,13 +131,13 @@ export class LambderDdbCache {
|
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
if (cached)
|
|
109
|
-
this.memory?.delete(
|
|
110
|
-
const pk = await this.partitionKey(
|
|
111
|
-
const manifest = await this.readManifest(pk);
|
|
134
|
+
this.memory?.delete(address.memoryKey);
|
|
135
|
+
const pk = await this.partitionKey(address.partition);
|
|
136
|
+
const manifest = await this.readManifest(pk, address);
|
|
112
137
|
if (!manifest || manifest.expiresAt <= nowSeconds)
|
|
113
138
|
return undefined;
|
|
114
139
|
try {
|
|
115
|
-
const stored = manifest.inlineData ?? await this.readChunks(pk, manifest);
|
|
140
|
+
const stored = manifest.inlineData ?? await this.readChunks(pk, address, manifest);
|
|
116
141
|
if (stored.length !== manifest.storedBytes) {
|
|
117
142
|
throw new Error("stored byte length does not match manifest");
|
|
118
143
|
}
|
|
@@ -121,28 +146,30 @@ export class LambderDdbCache {
|
|
|
121
146
|
}
|
|
122
147
|
const json = await this.decode(stored, manifest.encoding, manifest.uncompressedBytes);
|
|
123
148
|
const parsed = JSON.parse(json);
|
|
124
|
-
this.remember(
|
|
149
|
+
this.remember(address.memoryKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
|
|
125
150
|
return parsed;
|
|
126
151
|
}
|
|
127
152
|
catch (error) {
|
|
128
|
-
await this.invalidateManifest(pk, manifest.version);
|
|
153
|
+
await this.invalidateManifest(pk, address, manifest.version);
|
|
129
154
|
console.warn(`Ignoring corrupt DynamoDB cache entry in ${this.namespace}`, error);
|
|
130
155
|
return undefined;
|
|
131
156
|
}
|
|
132
157
|
}
|
|
133
158
|
async has(key) {
|
|
134
|
-
const
|
|
135
|
-
const cached = this.memory?.get(
|
|
159
|
+
const address = this.normalizeKey(key);
|
|
160
|
+
const cached = this.memory?.get(address.memoryKey);
|
|
136
161
|
const nowSeconds = this.nowSeconds();
|
|
137
162
|
if (cached?.expiresAt && cached.expiresAt > nowSeconds)
|
|
138
163
|
return true;
|
|
139
164
|
if (cached)
|
|
140
|
-
this.memory?.delete(
|
|
141
|
-
const manifest = await this.readManifest(await this.partitionKey(
|
|
165
|
+
this.memory?.delete(address.memoryKey);
|
|
166
|
+
const manifest = await this.readManifest(await this.partitionKey(address.partition), address);
|
|
142
167
|
return !!manifest && manifest.expiresAt > nowSeconds;
|
|
143
168
|
}
|
|
144
169
|
async set(key, value, options = {}) {
|
|
145
|
-
|
|
170
|
+
return await this.setByAddress(this.normalizeKey(key), value, options);
|
|
171
|
+
}
|
|
172
|
+
async setByAddress(address, value, options) {
|
|
146
173
|
const ttlSeconds = positiveInteger(options.ttlSeconds ?? this.defaultTtlSeconds, "ttlSeconds");
|
|
147
174
|
const json = JSON.stringify(value);
|
|
148
175
|
if (json === undefined)
|
|
@@ -157,7 +184,7 @@ export class LambderDdbCache {
|
|
|
157
184
|
if (stored.length > this.maxValueBytes) {
|
|
158
185
|
throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
|
|
159
186
|
}
|
|
160
|
-
const pk = await this.partitionKey(
|
|
187
|
+
const pk = await this.partitionKey(address.partition);
|
|
161
188
|
const version = `${Date.now().toString(36)}-${await randomUUID()}`;
|
|
162
189
|
const expiresAt = this.nowSeconds() + ttlSeconds;
|
|
163
190
|
const chunks = [];
|
|
@@ -171,7 +198,7 @@ export class LambderDdbCache {
|
|
|
171
198
|
PutRequest: {
|
|
172
199
|
Item: {
|
|
173
200
|
pk: { S: pk },
|
|
174
|
-
sk: { S: this.chunkSortKey(version, index) },
|
|
201
|
+
sk: { S: this.chunkSortKey(address, version, index) },
|
|
175
202
|
data: { B: chunk },
|
|
176
203
|
expiresAt: { N: String(expiresAt) },
|
|
177
204
|
},
|
|
@@ -182,7 +209,7 @@ export class LambderDdbCache {
|
|
|
182
209
|
TableName: this.tableName,
|
|
183
210
|
Item: {
|
|
184
211
|
pk: { S: pk },
|
|
185
|
-
sk: { S: META_SORT_KEY },
|
|
212
|
+
sk: { S: this.itemSortKey(address, META_SORT_KEY) },
|
|
186
213
|
version: { S: version },
|
|
187
214
|
chunkCount: { N: String(chunks.length) },
|
|
188
215
|
storedBytes: { N: String(stored.length) },
|
|
@@ -194,41 +221,70 @@ export class LambderDdbCache {
|
|
|
194
221
|
...(inline ? { data: { B: stored } } : {}),
|
|
195
222
|
},
|
|
196
223
|
}));
|
|
197
|
-
this.remember(
|
|
224
|
+
this.remember(address.memoryKey, stored, encoding, input.length, expiresAt);
|
|
198
225
|
}
|
|
199
226
|
async delete(key) {
|
|
200
|
-
const
|
|
201
|
-
const pk = await this.partitionKey(
|
|
202
|
-
this.memory?.delete(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
const address = this.normalizeKey(key);
|
|
228
|
+
const pk = await this.partitionKey(address.partition);
|
|
229
|
+
this.memory?.delete(address.memoryKey);
|
|
230
|
+
// A grouped entry owns one contiguous item range; a plain-string one
|
|
231
|
+
// owns the bare item keys, so it must leave any grouped entries
|
|
232
|
+
// sharing its partition alone.
|
|
233
|
+
const prefix = this.entryItemPrefix(address);
|
|
234
|
+
const items = await this.queryItems(pk, { prefix, projection: "#pk, #sk" });
|
|
235
|
+
const owned = prefix ? items : items.filter((item) => !item.sk?.S?.startsWith(SORT_KEY_MARKER));
|
|
236
|
+
await this.deleteItems(owned);
|
|
237
|
+
return owned.length > 0;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Drop every entry stored under one `pk`, without knowing which sort keys
|
|
241
|
+
* exist: the invalidation a group of related entries is worth grouping
|
|
242
|
+
* for. Returns the number of entries removed. In-memory copies held by
|
|
243
|
+
* OTHER Lambda containers still serve until their own TTL, as they do
|
|
244
|
+
* after a single-entry delete.
|
|
245
|
+
*/
|
|
246
|
+
async deletePartition(partition) {
|
|
247
|
+
const normalized = this.normalizePartition(partition);
|
|
248
|
+
const pk = await this.partitionKey(normalized);
|
|
249
|
+
this.forgetPartition(normalized);
|
|
250
|
+
const items = await this.queryItems(pk, { projection: "#pk, #sk" });
|
|
251
|
+
await this.deleteItems(items);
|
|
252
|
+
return items.filter((item) => this.isManifestSortKey(item.sk?.S)).length;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The live (unexpired) sort keys stored under one `pk`, in table order.
|
|
256
|
+
* Plain-string entries have no sort key, so they never appear here.
|
|
257
|
+
* Reading a partition whose values are chunked also reads those chunk
|
|
258
|
+
* items, so grouping very large values makes listing more expensive.
|
|
259
|
+
*/
|
|
260
|
+
async listSortKeys(partition, options = {}) {
|
|
261
|
+
const pk = await this.partitionKey(this.normalizePartition(partition));
|
|
262
|
+
const prefix = `${SORT_KEY_MARKER}${encodeSortKey(options.prefix ?? "")}`;
|
|
263
|
+
const limit = options.limit === undefined ? undefined : positiveInteger(options.limit, "limit");
|
|
264
|
+
const nowSeconds = this.nowSeconds();
|
|
265
|
+
const items = await this.queryItems(pk, { prefix, projection: "#sk, #expiresAt", extraNames: { "#expiresAt": "expiresAt" } });
|
|
266
|
+
const sortKeys = [];
|
|
267
|
+
for (const item of items) {
|
|
268
|
+
const sk = item.sk?.S;
|
|
269
|
+
if (!sk || !this.isManifestSortKey(sk))
|
|
270
|
+
continue;
|
|
271
|
+
if (Number(item.expiresAt?.N) <= nowSeconds)
|
|
272
|
+
continue;
|
|
273
|
+
sortKeys.push(decodeSortKey(sk.slice(SORT_KEY_MARKER.length, -(META_SORT_KEY.length + 1))));
|
|
274
|
+
if (limit !== undefined && sortKeys.length >= limit)
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
return sortKeys;
|
|
222
278
|
}
|
|
223
279
|
async getOrSet(key, factory, options = {}) {
|
|
224
|
-
const
|
|
225
|
-
const current = this.inFlight.get(
|
|
280
|
+
const address = this.normalizeKey(key);
|
|
281
|
+
const current = this.inFlight.get(address.memoryKey);
|
|
226
282
|
if (current)
|
|
227
283
|
return current;
|
|
228
|
-
const fill = this.getOrSetFailOpen(
|
|
229
|
-
this.inFlight.delete(
|
|
284
|
+
const fill = this.getOrSetFailOpen(address, factory, options).finally(() => {
|
|
285
|
+
this.inFlight.delete(address.memoryKey);
|
|
230
286
|
});
|
|
231
|
-
this.inFlight.set(
|
|
287
|
+
this.inFlight.set(address.memoryKey, fill);
|
|
232
288
|
return fill;
|
|
233
289
|
}
|
|
234
290
|
/**
|
|
@@ -236,7 +292,7 @@ export class LambderDdbCache {
|
|
|
236
292
|
* failures return the loader value. Loader failures still propagate and the
|
|
237
293
|
* loader is never repeated after it has completed successfully.
|
|
238
294
|
*/
|
|
239
|
-
async getOrSetFailOpen(
|
|
295
|
+
async getOrSetFailOpen(address, factory, options) {
|
|
240
296
|
let factoryStarted = false;
|
|
241
297
|
let factoryCompleted = false;
|
|
242
298
|
let factoryValue;
|
|
@@ -247,64 +303,64 @@ export class LambderDdbCache {
|
|
|
247
303
|
return factoryValue;
|
|
248
304
|
};
|
|
249
305
|
try {
|
|
250
|
-
const existing = await this.
|
|
306
|
+
const existing = await this.getByAddress(address);
|
|
251
307
|
if (existing !== undefined)
|
|
252
308
|
return existing;
|
|
253
|
-
return await this.fill(
|
|
309
|
+
return await this.fill(address, trackedFactory, options);
|
|
254
310
|
}
|
|
255
311
|
catch (error) {
|
|
256
312
|
if (factoryStarted && !factoryCompleted)
|
|
257
313
|
throw error;
|
|
258
|
-
console.error(`DynamoDB cache failed open in ${this.namespace} for ${
|
|
314
|
+
console.error(`DynamoDB cache failed open in ${this.namespace} for ${address.memoryKey}`, error);
|
|
259
315
|
if (factoryCompleted)
|
|
260
316
|
return factoryValue;
|
|
261
317
|
return trackedFactory();
|
|
262
318
|
}
|
|
263
319
|
}
|
|
264
|
-
async fill(
|
|
320
|
+
async fill(address, factory, options) {
|
|
265
321
|
const leaseSeconds = positiveInteger(options.leaseSeconds ?? 15, "leaseSeconds");
|
|
266
322
|
const waitForFillMs = positiveInteger(options.waitForFillMs ?? 5_000, "waitForFillMs");
|
|
267
|
-
const pk = await this.partitionKey(
|
|
323
|
+
const pk = await this.partitionKey(address.partition);
|
|
268
324
|
const owner = await randomUUID();
|
|
269
|
-
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
325
|
+
if (await this.acquireLease(pk, address, owner, leaseSeconds)) {
|
|
270
326
|
try {
|
|
271
327
|
const value = await factory();
|
|
272
|
-
await this.
|
|
328
|
+
await this.setByAddress(address, value, { ttlSeconds: options.ttlSeconds });
|
|
273
329
|
return value;
|
|
274
330
|
}
|
|
275
331
|
finally {
|
|
276
|
-
await this.releaseLease(pk, owner);
|
|
332
|
+
await this.releaseLease(pk, address, owner);
|
|
277
333
|
}
|
|
278
334
|
}
|
|
279
335
|
const deadline = Date.now() + waitForFillMs;
|
|
280
336
|
let delay = 50;
|
|
281
337
|
while (Date.now() < deadline) {
|
|
282
338
|
await sleep(delay + Math.floor(Math.random() * 25));
|
|
283
|
-
const value = await this.
|
|
339
|
+
const value = await this.getByAddress(address);
|
|
284
340
|
if (value !== undefined)
|
|
285
341
|
return value;
|
|
286
|
-
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
342
|
+
if (await this.acquireLease(pk, address, owner, leaseSeconds)) {
|
|
287
343
|
try {
|
|
288
344
|
const loaded = await factory();
|
|
289
|
-
await this.
|
|
345
|
+
await this.setByAddress(address, loaded, { ttlSeconds: options.ttlSeconds });
|
|
290
346
|
return loaded;
|
|
291
347
|
}
|
|
292
348
|
finally {
|
|
293
|
-
await this.releaseLease(pk, owner);
|
|
349
|
+
await this.releaseLease(pk, address, owner);
|
|
294
350
|
}
|
|
295
351
|
}
|
|
296
352
|
delay = Math.min(delay * 2, 500);
|
|
297
353
|
}
|
|
298
354
|
throw new Error(`Timed out waiting for DynamoDB cache fill in ${this.namespace}`);
|
|
299
355
|
}
|
|
300
|
-
async acquireLease(pk, owner, leaseSeconds) {
|
|
356
|
+
async acquireLease(pk, address, owner, leaseSeconds) {
|
|
301
357
|
const now = this.nowSeconds();
|
|
302
358
|
try {
|
|
303
359
|
await this.client.send(new PutItemCommand({
|
|
304
360
|
TableName: this.tableName,
|
|
305
361
|
Item: {
|
|
306
362
|
pk: { S: pk },
|
|
307
|
-
sk: { S: LOCK_SORT_KEY },
|
|
363
|
+
sk: { S: this.itemSortKey(address, LOCK_SORT_KEY) },
|
|
308
364
|
owner: { S: owner },
|
|
309
365
|
expiresAt: { N: String(now + leaseSeconds) },
|
|
310
366
|
},
|
|
@@ -320,11 +376,11 @@ export class LambderDdbCache {
|
|
|
320
376
|
throw error;
|
|
321
377
|
}
|
|
322
378
|
}
|
|
323
|
-
async releaseLease(pk, owner) {
|
|
379
|
+
async releaseLease(pk, address, owner) {
|
|
324
380
|
try {
|
|
325
381
|
await this.client.send(new DeleteItemCommand({
|
|
326
382
|
TableName: this.tableName,
|
|
327
|
-
Key: { pk: { S: pk }, sk: { S: LOCK_SORT_KEY } },
|
|
383
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, LOCK_SORT_KEY) } },
|
|
328
384
|
ConditionExpression: "#owner = :owner",
|
|
329
385
|
ExpressionAttributeNames: { "#owner": "owner" },
|
|
330
386
|
ExpressionAttributeValues: { ":owner": { S: owner } },
|
|
@@ -336,10 +392,10 @@ export class LambderDdbCache {
|
|
|
336
392
|
}
|
|
337
393
|
}
|
|
338
394
|
}
|
|
339
|
-
async readManifest(pk) {
|
|
395
|
+
async readManifest(pk, address) {
|
|
340
396
|
const response = await this.client.send(new GetItemCommand({
|
|
341
397
|
TableName: this.tableName,
|
|
342
|
-
Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
|
|
398
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
343
399
|
ConsistentRead: false,
|
|
344
400
|
}));
|
|
345
401
|
const item = response.Item;
|
|
@@ -387,43 +443,60 @@ export class LambderDdbCache {
|
|
|
387
443
|
inlineData,
|
|
388
444
|
};
|
|
389
445
|
}
|
|
390
|
-
async readChunks(pk, manifest) {
|
|
391
|
-
const prefix =
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#data": "data" },
|
|
401
|
-
ExclusiveStartKey: cursor,
|
|
402
|
-
ConsistentRead: false,
|
|
403
|
-
}));
|
|
404
|
-
for (const item of response.Items ?? []) {
|
|
405
|
-
if (item.sk?.S && item.data?.B) {
|
|
406
|
-
chunks.push({ sk: item.sk.S, data: Buffer.from(item.data.B) });
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
cursor = response.LastEvaluatedKey;
|
|
410
|
-
} while (cursor);
|
|
446
|
+
async readChunks(pk, address, manifest) {
|
|
447
|
+
const prefix = this.itemSortKey(address, `${CHUNK_SORT_KEY_PREFIX}${manifest.version}#`);
|
|
448
|
+
const items = await this.queryItems(pk, {
|
|
449
|
+
prefix,
|
|
450
|
+
projection: "#sk, #data",
|
|
451
|
+
extraNames: { "#data": "data" },
|
|
452
|
+
});
|
|
453
|
+
const chunks = items
|
|
454
|
+
.filter((item) => item.sk?.S && item.data?.B)
|
|
455
|
+
.map((item) => ({ sk: item.sk.S, data: Buffer.from(item.data.B) }));
|
|
411
456
|
chunks.sort((left, right) => left.sk.localeCompare(right.sk));
|
|
412
457
|
if (chunks.length !== manifest.chunkCount) {
|
|
413
458
|
throw new Error(`DynamoDB cache entry is missing chunks (${chunks.length}/${manifest.chunkCount})`);
|
|
414
459
|
}
|
|
415
460
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
416
|
-
if (chunks[index]?.sk !== this.chunkSortKey(manifest.version, index)) {
|
|
461
|
+
if (chunks[index]?.sk !== this.chunkSortKey(address, manifest.version, index)) {
|
|
417
462
|
throw new Error(`DynamoDB cache entry has an invalid chunk index at ${index}`);
|
|
418
463
|
}
|
|
419
464
|
}
|
|
420
465
|
return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.storedBytes);
|
|
421
466
|
}
|
|
422
|
-
|
|
467
|
+
/** Every item matching a partition (optionally a sort-key prefix), following pagination. */
|
|
468
|
+
async queryItems(pk, options) {
|
|
469
|
+
const items = [];
|
|
470
|
+
let cursor;
|
|
471
|
+
do {
|
|
472
|
+
const response = await this.client.send(new QueryCommand({
|
|
473
|
+
TableName: this.tableName,
|
|
474
|
+
KeyConditionExpression: options.prefix
|
|
475
|
+
? "#pk = :pk AND begins_with(#sk, :prefix)"
|
|
476
|
+
: "#pk = :pk",
|
|
477
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", ...options.extraNames },
|
|
478
|
+
ExpressionAttributeValues: {
|
|
479
|
+
":pk": { S: pk },
|
|
480
|
+
...(options.prefix ? { ":prefix": { S: options.prefix } } : {}),
|
|
481
|
+
},
|
|
482
|
+
ProjectionExpression: options.projection,
|
|
483
|
+
ExclusiveStartKey: cursor,
|
|
484
|
+
ConsistentRead: false,
|
|
485
|
+
}));
|
|
486
|
+
items.push(...(response.Items ?? []));
|
|
487
|
+
cursor = response.LastEvaluatedKey;
|
|
488
|
+
} while (cursor);
|
|
489
|
+
return items;
|
|
490
|
+
}
|
|
491
|
+
async deleteItems(items) {
|
|
492
|
+
const keys = items.flatMap((item) => item.pk && item.sk ? [{ pk: item.pk, sk: item.sk }] : []);
|
|
493
|
+
await this.batchWrite(keys.map((Key) => ({ DeleteRequest: { Key } })));
|
|
494
|
+
}
|
|
495
|
+
async invalidateManifest(pk, address, version) {
|
|
423
496
|
try {
|
|
424
497
|
await this.client.send(new DeleteItemCommand({
|
|
425
498
|
TableName: this.tableName,
|
|
426
|
-
Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
|
|
499
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
427
500
|
ConditionExpression: "#version = :version",
|
|
428
501
|
ExpressionAttributeNames: { "#version": "version" },
|
|
429
502
|
ExpressionAttributeValues: { ":version": { S: version } },
|
|
@@ -464,6 +537,23 @@ export class LambderDdbCache {
|
|
|
464
537
|
this.memory.set(key, { stored, encoding, uncompressedBytes, expiresAt }, { ttl });
|
|
465
538
|
}
|
|
466
539
|
normalizeKey(key) {
|
|
540
|
+
if (typeof key === "string") {
|
|
541
|
+
const partition = this.normalizePartition(key);
|
|
542
|
+
return { partition, sortKey: null, memoryKey: this.memoryKeyOf(partition, null) };
|
|
543
|
+
}
|
|
544
|
+
if (!key || typeof key !== "object")
|
|
545
|
+
throw new Error("Cache key is required");
|
|
546
|
+
const partition = this.normalizePartition(key.pk);
|
|
547
|
+
const sortKey = key.sk;
|
|
548
|
+
if (typeof sortKey !== "string" || !sortKey.trim())
|
|
549
|
+
throw new Error("Cache sort key is required");
|
|
550
|
+
const encodedBytes = Buffer.byteLength(encodeSortKey(sortKey), "utf8");
|
|
551
|
+
if (encodedBytes > MAX_SORT_KEY_BYTES) {
|
|
552
|
+
throw new Error(`Cache sort key must be at most ${MAX_SORT_KEY_BYTES} UTF-8 bytes once escaped (${encodedBytes})`);
|
|
553
|
+
}
|
|
554
|
+
return { partition, sortKey, memoryKey: this.memoryKeyOf(partition, sortKey) };
|
|
555
|
+
}
|
|
556
|
+
normalizePartition(key) {
|
|
467
557
|
if (typeof key !== "string" || !key.trim())
|
|
468
558
|
throw new Error("Cache key is required");
|
|
469
559
|
if (Buffer.byteLength(key, "utf8") > 8 * 1024) {
|
|
@@ -471,11 +561,41 @@ export class LambderDdbCache {
|
|
|
471
561
|
}
|
|
472
562
|
return key;
|
|
473
563
|
}
|
|
564
|
+
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
565
|
+
memoryKeyOf(partition, sortKey) {
|
|
566
|
+
return `${partition.length}:${partition}#${sortKey ?? ""}`;
|
|
567
|
+
}
|
|
474
568
|
async partitionKey(key) {
|
|
475
569
|
return `${this.keyPrefix}#${this.namespace}#${await sha256(key)}`;
|
|
476
570
|
}
|
|
477
|
-
|
|
478
|
-
|
|
571
|
+
/**
|
|
572
|
+
* One of an entry's item keys. A plain-string entry keeps the bare
|
|
573
|
+
* suffix it has always used; a grouped one nests under its escaped sort
|
|
574
|
+
* key, whose trailing `#` is an unambiguous boundary because an escaped
|
|
575
|
+
* sort key never contains a bare `#`.
|
|
576
|
+
*/
|
|
577
|
+
itemSortKey(address, suffix) {
|
|
578
|
+
return address.sortKey === null ? suffix : `${SORT_KEY_MARKER}${encodeSortKey(address.sortKey)}#${suffix}`;
|
|
579
|
+
}
|
|
580
|
+
/** The prefix covering every item of a grouped entry; null for a plain-string entry, which owns the bare item keys instead. */
|
|
581
|
+
entryItemPrefix(address) {
|
|
582
|
+
return address.sortKey === null ? null : `${SORT_KEY_MARKER}${encodeSortKey(address.sortKey)}#`;
|
|
583
|
+
}
|
|
584
|
+
isManifestSortKey(sk) {
|
|
585
|
+
return sk === META_SORT_KEY || (!!sk && sk.startsWith(SORT_KEY_MARKER) && sk.endsWith(`#${META_SORT_KEY}`));
|
|
586
|
+
}
|
|
587
|
+
chunkSortKey(address, version, index) {
|
|
588
|
+
return this.itemSortKey(address, `${CHUNK_SORT_KEY_PREFIX}${version}#${String(index).padStart(6, "0")}`);
|
|
589
|
+
}
|
|
590
|
+
/** Drop every in-memory copy belonging to one partition. */
|
|
591
|
+
forgetPartition(partition) {
|
|
592
|
+
if (!this.memory)
|
|
593
|
+
return;
|
|
594
|
+
const prefix = this.memoryKeyOf(partition, "");
|
|
595
|
+
for (const key of [...this.memory.keys()]) {
|
|
596
|
+
if (key.startsWith(prefix))
|
|
597
|
+
this.memory.delete(key);
|
|
598
|
+
}
|
|
479
599
|
}
|
|
480
600
|
nowSeconds() {
|
|
481
601
|
return Math.floor(Date.now() / 1000);
|