@everystack/server 0.4.37 → 0.4.39

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
@@ -4,6 +4,41 @@ All notable changes to `@everystack/server` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.39] - 2026-10-23
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
12
+ - one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
13
+ - deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
14
+
15
+ ## [0.4.38] - 2026-10-23
16
+
17
+ ### Added
18
+
19
+ - make a broken KVS purge loud with EMF metrics and kvsPurgeCheck
20
+ - cache.htmlByPath, a per-path Cache-Control for unauthenticated HTML
21
+ - db:provision creates the reserved lifecycle role (P2.7)
22
+ - everystack.purge_log, append-only
23
+ - purgeExpired, the core
24
+ - purge refuses a graph it does not understand
25
+ - purge cannot starve, and is bounded
26
+ - db:doctor reports lifecycle rows past the purge window
27
+
28
+ ### Fixed
29
+
30
+ - sign KVS purges with an explicit SigV4a signer
31
+ - KVS client shares the signer's credentials; prove the send path signs
32
+ - batch KVS purges and retry on ETag conflicts
33
+ - retry KVS purges per path with PutKey, not UpdateKeys
34
+ - a bad metrics setting never stops a KVS purge; check region and retryable faults
35
+ - htmlByPath matches the path the server renders (resolve dot segments)
36
+ - purge_log ensure revokes only from roles that hold a grant
37
+ - check purge deletion before destroy hooks
38
+ - surface purge after-destroy failures
39
+ - purge refuses an undeclared lifecycle child, and the revoke exempts the table owner
40
+ - a DELETE issued from inside a trigger on a lifecycle table is a soft delete
41
+
7
42
  ## [0.4.37] - 2026-10-18
8
43
 
9
44
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.37",
3
+ "version": "0.4.39",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -125,6 +125,11 @@
125
125
  "source": "./src/restore-witness.ts",
126
126
  "types": "./src/restore-witness.ts",
127
127
  "default": "./src/restore-witness.ts"
128
+ },
129
+ "./purge": {
130
+ "source": "./src/purge.ts",
131
+ "types": "./src/purge.ts",
132
+ "default": "./src/purge.ts"
128
133
  }
129
134
  },
130
135
  "peerDependencies": {
@@ -192,8 +197,11 @@
192
197
  "sst": "4.13.1",
193
198
  "ts-jest": "29.4.9",
194
199
  "typescript": "5.9.3",
195
- "@everystack/auth": "0.4.9",
196
- "@everystack/cli": "0.4.100"
200
+ "@everystack/cli": "0.4.103",
201
+ "@everystack/auth": "0.4.9"
202
+ },
203
+ "dependencies": {
204
+ "@everystack/model": "0.4.24"
197
205
  },
198
206
  "scripts": {
199
207
  "test": "jest",
@@ -0,0 +1,36 @@
1
+ import type { ServerCacheConfig } from './index';
2
+
3
+ const DEFAULT_HTML = 'public, max-age=0, s-maxage=60';
4
+
5
+ function normalize(path: string): string {
6
+ let p = path;
7
+ try { p = decodeURIComponent(path); } catch { /* malformed escape: match the raw path */ }
8
+ // The server renders the URL after dot segments are resolved (eventToRequest -> new URL), so
9
+ // `/x/../admin` is the admin shell. Collapse leading slashes first: `//admin` would otherwise
10
+ // parse as a host. Over-matching here only ever picks the stricter rule.
11
+ p = '/' + p.split('/').filter(Boolean).join('/');
12
+ try { p = new URL(p, 'http://h').pathname; } catch { /* keep the collapsed path */ }
13
+ return '/' + p.split('/').filter(Boolean).join('/');
14
+ }
15
+
16
+ /**
17
+ * Cache-Control for an unauthenticated HTML GET: the longest `htmlByPath` prefix that matches
18
+ * whole path segments, else `html`, else the built-in default. The path is decoded, its slashes
19
+ * collapsed and its dot segments resolved first, so `/%61dmin`, `//admin` and `/x/../admin` cannot
20
+ * slip past a rule on `/admin`. Keys that normalize alike: the first declared wins.
21
+ */
22
+ export function htmlCacheControl(path: string, cache: ServerCacheConfig | undefined): string {
23
+ const rules = cache?.htmlByPath;
24
+ if (rules) {
25
+ const target = normalize(path);
26
+ let best = '';
27
+ let value: string | undefined;
28
+ for (const [prefix, cc] of Object.entries(rules)) {
29
+ const p = normalize(prefix);
30
+ const hit = p === '/' || target === p || target.startsWith(p + '/');
31
+ if (hit && (value === undefined || p.length > best.length)) { best = p; value = cc; }
32
+ }
33
+ if (value !== undefined) return value;
34
+ }
35
+ return cache?.html ?? DEFAULT_HTML;
36
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  import { log } from './log';
19
19
  import { buildTimingHeaders } from './timing';
20
20
  import { DEFAULT_STRIP_QUERY_PARAMS, stripQueryParams } from './strip-query-params';
21
+ import { htmlCacheControl } from './cache-control';
21
22
 
22
23
  export { buildTimingHeaders } from './timing';
23
24
  export type { TimingHeaderOptions } from './timing';
@@ -72,6 +73,11 @@ export interface ServerCacheConfig {
72
73
  * with no CloudFront invalidation. No stale-while-revalidate: the cap is hard.
73
74
  * Default: 'public, max-age=0, s-maxage=60' */
74
75
  html?: string;
76
+ /** Per-path override of `html`: path prefix → Cache-Control, for unauthenticated HTML.
77
+ * Keys match whole path segments (`/admin` covers `/admin` and `/admin/x`, not
78
+ * `/administrator`); the longest match wins; no match falls back to `html`.
79
+ * Example: `{ '/admin': 'private, no-store' }`. */
80
+ htmlByPath?: Record<string, string>;
75
81
  /** Authenticated GET/HEAD responses.
76
82
  * Default: 'private, no-store' */
77
83
  private?: string;
@@ -295,7 +301,7 @@ export function createLambdaHandler(
295
301
  // GETs are version-keyed and can cache long.
296
302
  const isHtml = String(result.headers['content-type'] || '').includes('text/html');
297
303
  result.headers['cache-control'] = isHtml
298
- ? (options.cache?.html ?? 'public, max-age=0, s-maxage=60')
304
+ ? htmlCacheControl(path, options.cache)
299
305
  : (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
300
306
  }
301
307
  } else {
@@ -0,0 +1,153 @@
1
+ /**
2
+ * CloudWatch embedded metric format (EMF) lines for the KVS purge path.
3
+ *
4
+ * A line written to stdout in Lambda becomes a CloudWatch metric with no SDK
5
+ * call and no extra IAM. It is written with `process.stdout.write`, never a
6
+ * `console` method: under the Lambda JSON log format, Lambda wraps console
7
+ * output in its own JSON event and CloudWatch does not extract metrics from
8
+ * the wrapped object.
9
+ */
10
+
11
+ export const DEFAULT_KVS_METRICS_NAMESPACE = 'Everystack/Cache';
12
+ export const MAX_KVS_METRIC_DIMENSIONS = 2;
13
+ /** The unpurgedPaths property keeps this many entries; the metric keeps the full count. */
14
+ export const MAX_LOGGED_UNPURGED_PATHS = 20;
15
+
16
+ export type KvsMetricName =
17
+ | 'KvsPurgeFailure'
18
+ | 'KvsPurgePaths'
19
+ | 'KvsPurgeUnpurgedPaths'
20
+ | 'KvsPurgeCheckFailure';
21
+
22
+ export interface KvsMetricsOptions {
23
+ /** CloudWatch namespace. Default `Everystack/Cache`. */
24
+ namespace?: string;
25
+ /**
26
+ * Replaces the default dimension (`FunctionName` from
27
+ * AWS_LAMBDA_FUNCTION_NAME). At most 2 keys; `{}` means no dimension.
28
+ */
29
+ dimensions?: Record<string, string>;
30
+ /** Written as a property so one request can be found in Logs Insights. */
31
+ requestId?: string;
32
+ }
33
+
34
+ /** `false` turns emission off. */
35
+ export type KvsMetricsSetting = false | KvsMetricsOptions;
36
+
37
+ export interface ResolvedKvsMetrics {
38
+ namespace: string;
39
+ dimensions: Record<string, string>;
40
+ requestId?: string;
41
+ }
42
+
43
+ export interface KvsMetricPoint {
44
+ kvsArn: string;
45
+ metrics: Partial<Record<KvsMetricName, number>>;
46
+ unpurgedPaths?: string[];
47
+ }
48
+
49
+ const PROPERTY_NAMES = ['_aws', 'kvsArn', 'source', 'unpurgedPaths', 'requestId'];
50
+ const METRIC_NAMES: KvsMetricName[] = [
51
+ 'KvsPurgeFailure',
52
+ 'KvsPurgePaths',
53
+ 'KvsPurgeUnpurgedPaths',
54
+ 'KvsPurgeCheckFailure',
55
+ ];
56
+ const RESERVED = new Set<string>([...PROPERTY_NAMES, ...METRIC_NAMES]);
57
+
58
+ /**
59
+ * Resolve the namespace and dimensions once, at call time. Returns null when
60
+ * metrics are off. Throws on more than 2 dimensions or a reserved dimension
61
+ * name; the purge and the check catch that through resolveKvsMetricsOrDefault.
62
+ */
63
+ export function resolveKvsMetrics(
64
+ setting?: KvsMetricsSetting,
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ ): ResolvedKvsMetrics | null {
67
+ if (setting === false) return null;
68
+ const options = setting ?? {};
69
+
70
+ let dimensions: Record<string, string>;
71
+ if (options.dimensions) {
72
+ const names = Object.keys(options.dimensions);
73
+ if (names.length > MAX_KVS_METRIC_DIMENSIONS) {
74
+ throw new Error(
75
+ `KVS metrics: at most ${MAX_KVS_METRIC_DIMENSIONS} dimensions, got ${names.length} `
76
+ + `(${names.join(', ')}). Put high-cardinality values in properties, not dimensions.`,
77
+ );
78
+ }
79
+ const reserved = names.filter((n) => RESERVED.has(n));
80
+ if (reserved.length > 0) {
81
+ throw new Error(
82
+ `KVS metrics: dimension name ${reserved.join(', ')} is reserved for a property or metric.`,
83
+ );
84
+ }
85
+ dimensions = { ...options.dimensions };
86
+ } else {
87
+ const fn = env.AWS_LAMBDA_FUNCTION_NAME;
88
+ dimensions = fn ? { FunctionName: fn } : {};
89
+ }
90
+
91
+ return {
92
+ namespace: options.namespace ?? DEFAULT_KVS_METRICS_NAMESPACE,
93
+ dimensions,
94
+ ...(options.requestId ? { requestId: options.requestId } : {}),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Resolve the setting for a purge or a check. A bad setting must never stop a
100
+ * purge (stale beats down): log one error line naming the problem and fall
101
+ * back to the default metrics.
102
+ */
103
+ export function resolveKvsMetricsOrDefault(
104
+ setting?: KvsMetricsSetting,
105
+ env: NodeJS.ProcessEnv = process.env,
106
+ ): ResolvedKvsMetrics | null {
107
+ try {
108
+ return resolveKvsMetrics(setting, env);
109
+ } catch (err) {
110
+ console.error(`KVS metrics setting ignored, using the defaults: ${(err as Error).message}`);
111
+ return resolveKvsMetrics(undefined, env);
112
+ }
113
+ }
114
+
115
+ /** Build one EMF object. Dimensions and metrics are top-level members. */
116
+ export function buildKvsEmf(
117
+ resolved: ResolvedKvsMetrics,
118
+ point: KvsMetricPoint,
119
+ now: number = Date.now(),
120
+ ): Record<string, unknown> {
121
+ const names = METRIC_NAMES.filter((n) => point.metrics[n] !== undefined);
122
+ return {
123
+ _aws: {
124
+ Timestamp: now,
125
+ CloudWatchMetrics: [{
126
+ Namespace: resolved.namespace,
127
+ Dimensions: [Object.keys(resolved.dimensions)],
128
+ Metrics: names.map((Name) => ({ Name, Unit: 'Count' })),
129
+ }],
130
+ },
131
+ ...resolved.dimensions,
132
+ ...Object.fromEntries(names.map((n) => [n, point.metrics[n]])),
133
+ kvsArn: point.kvsArn,
134
+ source: 'server',
135
+ ...(point.unpurgedPaths
136
+ ? { unpurgedPaths: point.unpurgedPaths.slice(0, MAX_LOGGED_UNPURGED_PATHS) }
137
+ : {}),
138
+ ...(resolved.requestId ? { requestId: resolved.requestId } : {}),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Write one EMF line to stdout. Never throws: a metric that cannot be written
144
+ * must not turn a purge, or a purge failure, into a different error.
145
+ */
146
+ export function emitKvsMetrics(resolved: ResolvedKvsMetrics | null, point: KvsMetricPoint): void {
147
+ if (!resolved) return;
148
+ try {
149
+ process.stdout.write(JSON.stringify(buildKvsEmf(resolved, point)) + '\n');
150
+ } catch {
151
+ // Nothing to do: the metric is lost, the caller's outcome is not.
152
+ }
153
+ }
package/src/kvs.ts CHANGED
@@ -9,42 +9,339 @@
9
9
  * import { invalidateKvsPaths } from '@everystack/server/kvs';
10
10
  * await invalidateKvsPaths(kvsArn, ['/api/posts', '/api/profiles']);
11
11
  */
12
+ import type { CloudFrontKeyValueStoreClient } from '@aws-sdk/client-cloudfront-keyvaluestore';
13
+ import { emitKvsMetrics, resolveKvsMetricsOrDefault, type KvsMetricsSetting } from './kvs-metrics';
14
+
15
+ export {
16
+ buildKvsEmf,
17
+ DEFAULT_KVS_METRICS_NAMESPACE,
18
+ emitKvsMetrics,
19
+ resolveKvsMetrics,
20
+ resolveKvsMetricsOrDefault,
21
+ type KvsMetricName,
22
+ type KvsMetricPoint,
23
+ type KvsMetricsOptions,
24
+ type KvsMetricsSetting,
25
+ type ResolvedKvsMetrics,
26
+ } from './kvs-metrics';
27
+
28
+ /**
29
+ * Build a KVS client that signs with an explicit SigV4a signer.
30
+ *
31
+ * KVS endpoints require SigV4a. A side-effect `import('@aws-sdk/signature-v4a')`
32
+ * registers the signer into one copy of @smithy/signature-v4, but the KVS client
33
+ * can carry a nested copy whose container is empty, and every request then fails
34
+ * with "Neither CRT nor JS SigV4a implementation is available". When
35
+ * `config.signer` is set the SDK uses it directly and reads no container.
36
+ *
37
+ * The credentials provider and sha256 come from a throwaway client, so the
38
+ * signer uses the same default chain and hash the SDK would have used.
39
+ * Same fix as `putKvsKey` in packages/cli/src/cli/aws.ts.
40
+ */
41
+ export async function createKvsClient(region?: string): Promise<CloudFrontKeyValueStoreClient> {
42
+ const { SignatureV4a } = await import('@aws-sdk/signature-v4a');
43
+ const { CloudFrontKeyValueStoreClient } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
44
+
45
+ const { credentials, sha256 } = new CloudFrontKeyValueStoreClient({ region }).config;
46
+ const signer = new SignatureV4a({
47
+ credentials,
48
+ region: '*',
49
+ service: 'cloudfront-keyvaluestore',
50
+ sha256,
51
+ });
52
+ // Same provider on the kept client, so there is one credential chain, not two.
53
+ return new CloudFrontKeyValueStoreClient({ region, signer, credentials });
54
+ }
55
+
56
+ /**
57
+ * One cached client promise per region. The credentials provider refreshes
58
+ * itself, so a long-lived client is safe; a build that fails is evicted so the
59
+ * next call tries again.
60
+ */
61
+ const clients = new Map<string, Promise<CloudFrontKeyValueStoreClient>>();
62
+
63
+ function getKvsClient(region?: string): Promise<CloudFrontKeyValueStoreClient> {
64
+ const key = region ?? '';
65
+ let client = clients.get(key);
66
+ if (!client) {
67
+ client = createKvsClient(region);
68
+ const built = client;
69
+ built.catch(() => {
70
+ if (clients.get(key) === built) clients.delete(key);
71
+ });
72
+ clients.set(key, client);
73
+ }
74
+ return client;
75
+ }
76
+
77
+ /** Test-only: drop the cached KVS clients so the next purge builds a new one. */
78
+ export function resetKvsClientForTests(): void {
79
+ clients.clear();
80
+ }
81
+
82
+ const DEFAULT_MAX_ATTEMPTS = 5;
83
+ const BACKOFF_BASE_MS = 25;
84
+ const BACKOFF_CAP_MS = 400;
85
+
86
+ export interface InvalidateKvsOptions {
87
+ /** Wait between conflict retries. Injected by tests. */
88
+ sleep?: (ms: number) => Promise<void>;
89
+ /** Attempts per path before giving up. Default 5. */
90
+ maxAttempts?: number;
91
+ /**
92
+ * One EMF line per call, success and failure. `false` turns it off. A bad
93
+ * setting (more than 2 dimensions, a reserved name) never stops the purge:
94
+ * it logs one error line and the default metrics are used.
95
+ */
96
+ metrics?: KvsMetricsSetting;
97
+ /**
98
+ * Region for the KVS client. Default: the SDK's own resolution. Give
99
+ * `kvsPurgeCheck` the same value so both test and use one client.
100
+ */
101
+ region?: string;
102
+ /**
103
+ * Called once on failure, before the KvsPurgeError is thrown. Never on
104
+ * success. If it throws or rejects, the KvsPurgeError is still thrown.
105
+ */
106
+ onError?: (error: KvsPurgeError) => void | Promise<void>;
107
+ }
108
+
109
+ /** A purge that stopped part way. `unpurgedPaths` are the keys NOT written. */
110
+ export class KvsPurgeError extends Error {
111
+ readonly unpurgedPaths: string[];
112
+ readonly writtenPaths: string[];
113
+
114
+ constructor(
115
+ message: string,
116
+ details: { unpurgedPaths: string[]; writtenPaths: string[]; cause: unknown },
117
+ ) {
118
+ super(message, { cause: details.cause });
119
+ this.name = 'KvsPurgeError';
120
+ this.unpurgedPaths = details.unpurgedPaths;
121
+ this.writtenPaths = details.writtenPaths;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * A write refused because another writer moved the ETag first. The KVS API
127
+ * models this as ConflictException (409); 412 PreconditionFailed is matched
128
+ * too in case the service ever answers with the HTTP precondition status.
129
+ */
130
+ function isEtagConflict(err: unknown): boolean {
131
+ const e = err as { name?: string; $metadata?: { httpStatusCode?: number } } | null;
132
+ const status = e?.$metadata?.httpStatusCode;
133
+ return status === 409 || status === 412
134
+ || e?.name === 'ConflictException'
135
+ || e?.name === 'PreconditionFailed'
136
+ || e?.name === 'PreconditionFailedException';
137
+ }
138
+
139
+ function backoffMs(attempt: number): number {
140
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
141
+ return ceiling / 2 + Math.random() * (ceiling / 2);
142
+ }
143
+
144
+ const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
12
145
 
13
146
  /**
14
147
  * Write a fresh timestamp to each KVS path, invalidating CloudFront caches.
15
148
  *
16
- * Each put changes the ETag, so we re-describe after each write.
17
- * Dynamically imports AWS SDK to avoid bundling when unused.
149
+ * Paths are deduped. Each put reads the current ETag and writes with IfMatch.
150
+ * When another writer moved the ETag first, that path re-reads and retries
151
+ * with a short jittered backoff. When a path cannot be written, or the client
152
+ * cannot be built, this throws a KvsPurgeError that lists the paths not
153
+ * written. Dynamically imports the AWS SDK to avoid bundling it when unused.
154
+ *
155
+ * Every call writes ONE EMF metric line after the retries are done, on success
156
+ * too (`KvsPurgeFailure` 0 or 1, `KvsPurgePaths`, `KvsPurgeUnpurgedPaths`), so
157
+ * a missing data point means the purge never ran, not that it was healthy.
18
158
  */
19
- export async function invalidateKvsPaths(kvsArn: string, paths: string[]): Promise<void> {
159
+ export async function invalidateKvsPaths(
160
+ kvsArn: string,
161
+ paths: string[],
162
+ options: InvalidateKvsOptions = {},
163
+ ): Promise<void> {
20
164
  if (!kvsArn || paths.length === 0) return;
21
165
 
22
- await import('@aws-sdk/signature-v4a');
23
- const {
24
- CloudFrontKeyValueStoreClient,
25
- DescribeKeyValueStoreCommand,
26
- PutKeyCommand,
27
- } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
28
-
29
- const client = new CloudFrontKeyValueStoreClient({});
30
- const desc = await client.send(
31
- new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
32
- );
33
- let etag = desc.ETag!;
34
-
35
- for (const path of paths) {
36
- await client.send(
37
- new PutKeyCommand({
38
- KvsARN: kvsArn,
39
- Key: path,
40
- Value: String(Math.floor(Date.now() / 1000)),
41
- IfMatch: etag,
42
- })
43
- );
44
- // KVS etag changes after each put — re-describe for next iteration
45
- const newDesc = await client.send(
46
- new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
166
+ const metrics = resolveKvsMetricsOrDefault(options.metrics);
167
+ const sleep = options.sleep ?? realSleep;
168
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
169
+ const unique = [...new Set(paths)];
170
+
171
+ let written = 0;
172
+ try {
173
+ const {
174
+ DescribeKeyValueStoreCommand,
175
+ PutKeyCommand,
176
+ } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
177
+
178
+ const client = await getKvsClient(options.region);
179
+ const value = String(Math.floor(Date.now() / 1000));
180
+
181
+ for (; written < unique.length; written++) {
182
+ for (let attempt = 1; ; attempt++) {
183
+ // The ETag moves after every put, ours or another writer's: read it fresh.
184
+ const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
185
+ try {
186
+ await client.send(new PutKeyCommand({
187
+ KvsARN: kvsArn,
188
+ Key: unique[written],
189
+ Value: value,
190
+ IfMatch: desc.ETag!,
191
+ }));
192
+ break;
193
+ } catch (err) {
194
+ if (!isEtagConflict(err) || attempt >= maxAttempts) throw err;
195
+ await sleep(backoffMs(attempt));
196
+ }
197
+ }
198
+ }
199
+ } catch (err) {
200
+ const unpurged = unique.slice(written);
201
+ const error = new KvsPurgeError(
202
+ `KVS purge stopped: ${written} of ${unique.length} paths written; `
203
+ + `${unpurged.length} ${unpurged.length === 1 ? 'path' : 'paths'} not purged`,
204
+ { unpurgedPaths: unpurged, writtenPaths: unique.slice(0, written), cause: err },
47
205
  );
48
- etag = newDesc.ETag!;
206
+ emitKvsMetrics(metrics, {
207
+ kvsArn,
208
+ metrics: { KvsPurgeFailure: 1, KvsPurgePaths: written, KvsPurgeUnpurgedPaths: unpurged.length },
209
+ unpurgedPaths: unpurged,
210
+ });
211
+ if (options.onError) {
212
+ try {
213
+ await options.onError(error);
214
+ } catch {
215
+ // The purge failure is the error the caller needs; a broken hook must not replace it.
216
+ }
217
+ }
218
+ throw error;
219
+ }
220
+
221
+ emitKvsMetrics(metrics, {
222
+ kvsArn,
223
+ metrics: { KvsPurgeFailure: 0, KvsPurgePaths: unique.length, KvsPurgeUnpurgedPaths: 0 },
224
+ unpurgedPaths: [],
225
+ });
226
+ }
227
+
228
+ const DEFAULT_CHECK_TIMEOUT_MS = 3000;
229
+
230
+ export interface KvsPurgeCheckOptions {
231
+ /** Give up after this many ms; a timeout is retryable. Default 3000. */
232
+ timeout?: number;
233
+ /**
234
+ * Region for the KVS client. Default: the SDK's own resolution. The check
235
+ * tests the same client the purges use only when both get the same region:
236
+ * none here and none on `invalidateKvsPaths`, or the same value on both.
237
+ */
238
+ region?: string;
239
+ /** Same setting as `invalidateKvsPaths`, with the same bad-setting fallback. */
240
+ metrics?: KvsMetricsSetting;
241
+ }
242
+
243
+ export type KvsPurgeCheckResult =
244
+ | { ok: true }
245
+ | { ok: false; retryable: boolean; error: unknown };
246
+
247
+ class KvsCheckTimeoutError extends Error {
248
+ constructor(ms: number) {
249
+ super(`KVS purge check timed out after ${ms}ms`);
250
+ this.name = 'KvsCheckTimeoutError';
251
+ }
252
+ }
253
+
254
+ const RETRYABLE_NAMES = new Set([
255
+ 'Throttling',
256
+ 'ThrottlingException',
257
+ 'TooManyRequestsException',
258
+ 'RequestTimeout',
259
+ 'RequestTimeoutException',
260
+ 'TimeoutError',
261
+ 'KvsCheckTimeoutError',
262
+ ]);
263
+ const NETWORK_CODES = new Set([
264
+ 'ECONNRESET',
265
+ 'ECONNREFUSED',
266
+ 'ETIMEDOUT',
267
+ 'EPIPE',
268
+ 'ENOTFOUND',
269
+ 'EAI_AGAIN',
270
+ 'ENETUNREACH',
271
+ 'EHOSTUNREACH',
272
+ 'ECONNABORTED',
273
+ ]);
274
+
275
+ /**
276
+ * Retryable: timeout, throttling, 429, 5xx, a server fault, an error the SDK
277
+ * marks retryable, network. Everything else (403, signer errors, validation,
278
+ * not found) is a broken purge path.
279
+ */
280
+ function isRetryableCheckError(err: unknown): boolean {
281
+ const e = err as {
282
+ name?: string;
283
+ code?: string;
284
+ $fault?: string;
285
+ $retryable?: unknown;
286
+ $metadata?: { httpStatusCode?: number };
287
+ } | null;
288
+ const status = e?.$metadata?.httpStatusCode;
289
+ if (status === 429 || (status !== undefined && status >= 500)) return true;
290
+ if (e?.$fault === 'server' || e?.$retryable) return true;
291
+ if (e?.name && RETRYABLE_NAMES.has(e.name)) return true;
292
+ return !!e?.code && NETWORK_CODES.has(e.code);
293
+ }
294
+
295
+ /**
296
+ * A read-only check that this Lambda can reach and sign for the store: ONE
297
+ * DescribeKeyValueStore through the cached client for `region`. With no
298
+ * `region`, that is the same client purges with no `region` use; pass the
299
+ * same `region` to both otherwise. It never writes, since a test write would
300
+ * change the store and race live purges. It never throws.
301
+ *
302
+ * Emits `KvsPurgeCheckFailure` 0 on a pass and 1 on a hard failure. A
303
+ * retryable failure emits nothing, so a cold-start burst does not page.
304
+ * Nothing calls it for you: call it from your Lambda `init` or a deploy smoke test.
305
+ */
306
+ export async function kvsPurgeCheck(
307
+ kvsArn: string,
308
+ options: KvsPurgeCheckOptions = {},
309
+ ): Promise<KvsPurgeCheckResult> {
310
+ const metrics = resolveKvsMetricsOrDefault(options.metrics);
311
+
312
+ const fail = (error: unknown): KvsPurgeCheckResult => {
313
+ const retryable = isRetryableCheckError(error);
314
+ if (!retryable) emitKvsMetrics(metrics, { kvsArn, metrics: { KvsPurgeCheckFailure: 1 } });
315
+ return { ok: false, retryable, error };
316
+ };
317
+
318
+ if (!kvsArn) return fail(new Error('kvsPurgeCheck: kvsArn is empty; purges would do nothing'));
319
+
320
+ const ms = options.timeout ?? DEFAULT_CHECK_TIMEOUT_MS;
321
+ const abort = new AbortController();
322
+ let timer: ReturnType<typeof setTimeout> | undefined;
323
+ const timedOut = new Promise<never>((_, reject) => {
324
+ timer = setTimeout(() => {
325
+ // Reject first so the race settles on the timeout, not on the abort it causes.
326
+ reject(new KvsCheckTimeoutError(ms));
327
+ abort.abort();
328
+ }, ms);
329
+ });
330
+
331
+ const describe = async (): Promise<void> => {
332
+ const { DescribeKeyValueStoreCommand } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
333
+ const client = await getKvsClient(options.region);
334
+ await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }), { abortSignal: abort.signal });
335
+ };
336
+
337
+ try {
338
+ await Promise.race([describe(), timedOut]);
339
+ } catch (error) {
340
+ return fail(error);
341
+ } finally {
342
+ clearTimeout(timer);
49
343
  }
344
+
345
+ emitKvsMetrics(metrics, { kvsArn, metrics: { KvsPurgeCheckFailure: 0 } });
346
+ return { ok: true };
50
347
  }