@everystack/events 0.4.2 → 0.4.3

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,14 @@ All notable changes to `@everystack/events` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.3] - 2026-10-23
8
+
9
+ ### Fixed
10
+
11
+ - sign KVS purges with an explicit SigV4a signer
12
+ - KVS client shares the signer's credentials; prove the send path signs
13
+ - cache_purge retries ETag conflicts and fails loudly
14
+
7
15
  ## [0.4.2] - 2026-10-15
8
16
 
9
17
  _Internal changes only._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/events",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "PostgreSQL LISTEN/NOTIFY bridge for serverless — WebSocket fan-out + Lambda invocation",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -64,7 +64,7 @@
64
64
  }
65
65
  },
66
66
  "dependencies": {
67
- "@everystack/model": "0.4.21"
67
+ "@everystack/model": "0.4.23"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@aws-sdk/client-apigatewaymanagementapi": "3.1053.0",
@@ -3,6 +3,8 @@ import type {
3
3
  RpcActionConfig, JobActionConfig, CachePurgeActionConfig,
4
4
  } from './types';
5
5
  import { resolveTemplate, filterByTenant, needsRowFetch, applyFilter } from './actions';
6
+ import { emitKvsMetrics, MAX_LOGGED_UNPURGED_PATHS, resolveKvsMetricsOrDefault, type KvsMetricsSetting } from './kvs-metrics';
7
+ import { backoffMs, DEFAULT_MAX_ATTEMPTS, isEtagConflict, KvsPurgeError, realSleep } from './kvs-purge';
6
8
 
7
9
  // --- Interfaces for injectable dependencies ---
8
10
 
@@ -16,12 +18,34 @@ export interface LambdaClient {
16
18
 
17
19
  export interface KvsClient {
18
20
  describe(kvsArn: string): Promise<{ ETag: string }>;
19
- put(kvsArn: string, key: string, value: string, etag: string): Promise<void>;
21
+ /**
22
+ * Write one key with IfMatch. Return the store's new ETag when the answer
23
+ * carries it, so the next path skips a Describe; return nothing and the
24
+ * next path describes first.
25
+ */
26
+ put(kvsArn: string, key: string, value: string, etag: string): Promise<void | { ETag?: string }>;
27
+ }
28
+
29
+ export interface CachePurgeOptions {
30
+ /** Wait between conflict retries. Test-only. */
31
+ sleep?: (ms: number) => Promise<void>;
32
+ /** Attempts per path before giving up. Default 5. */
33
+ maxAttempts?: number;
34
+ /**
35
+ * One EMF line per cache_purge dispatch, success and failure. `false` turns
36
+ * it off. A bad setting logs one error line and the defaults are used.
37
+ */
38
+ metrics?: KvsMetricsSetting;
20
39
  }
21
40
 
22
41
  export interface DispatchOptions {
23
42
  workerFunctionName?: string;
24
43
  defaultKvsArn?: string | null;
44
+ /** Called once per failed cache_purge. A throw or rejection is ignored. */
45
+ onError?: (error: KvsPurgeError) => void | Promise<void>;
46
+ metrics?: KvsMetricsSetting;
47
+ /** Wait between conflict retries. Test-only. */
48
+ sleep?: (ms: number) => Promise<void>;
25
49
  }
26
50
 
27
51
  // --- Individual dispatchers ---
@@ -79,7 +103,13 @@ export async function dispatchJob(
79
103
 
80
104
  /**
81
105
  * Dispatch a cache_purge action: invalidate CloudFront KVS paths.
82
- * Lightweight — runs directly in the listener, no Lambda invocation.
106
+ * Lightweight: runs directly in the listener, no Lambda invocation.
107
+ *
108
+ * Same rules as `invalidateKvsPaths` in packages/server/src/kvs.ts: paths are
109
+ * deduped; each put writes with IfMatch; on an ETag conflict that path
110
+ * re-reads the ETag and retries with a jittered backoff (5 attempts). A path
111
+ * that cannot be written stops the purge and throws a KvsPurgeError listing
112
+ * it and every later path. One EMF line per dispatch, after the retries.
83
113
  */
84
114
  export async function dispatchCachePurge(
85
115
  action: EventAction,
@@ -87,19 +117,82 @@ export async function dispatchCachePurge(
87
117
  row: Record<string, unknown> | null,
88
118
  kvsClient: KvsClient | null,
89
119
  defaultKvsArn: string | null,
120
+ options: CachePurgeOptions = {},
90
121
  ): Promise<void> {
91
122
  const resolved = resolveTemplate(action.config, event, row) as CachePurgeActionConfig;
92
123
  const kvsArn = resolved.kvsArn || defaultKvsArn;
93
- if (!kvsArn || !kvsClient) return;
124
+ if (!kvsArn || !kvsClient || resolved.paths.length === 0) return;
94
125
 
95
- const desc = await kvsClient.describe(kvsArn);
96
- let etag = desc.ETag;
126
+ // mirror of packages/server/src/kvs.ts, keep in sync
127
+ const metrics = resolveKvsMetricsOrDefault(options.metrics);
128
+ const sleep = options.sleep ?? realSleep;
129
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
130
+ const unique = [...new Set(resolved.paths)];
131
+ const value = String(Math.floor(Date.now() / 1000));
97
132
 
98
- for (const path of resolved.paths) {
99
- await kvsClient.put(kvsArn, path, String(Math.floor(Date.now() / 1000)), etag);
100
- // KVS etag changes after each put — re-describe for next iteration
101
- const newDesc = await kvsClient.describe(kvsArn);
102
- etag = newDesc.ETag;
133
+ let written = 0;
134
+ try {
135
+ // The ETag moves after every put, ours or another writer's. A put that
136
+ // returns the new ETag saves the next Describe; anything else reads it fresh.
137
+ let etag: string | undefined;
138
+ for (; written < unique.length; written++) {
139
+ for (let attempt = 1; ; attempt++) {
140
+ if (etag === undefined) etag = (await kvsClient.describe(kvsArn)).ETag;
141
+ try {
142
+ const out = await kvsClient.put(kvsArn, unique[written], value, etag);
143
+ etag = out ? out.ETag : undefined;
144
+ break;
145
+ } catch (err) {
146
+ etag = undefined;
147
+ if (!isEtagConflict(err) || attempt >= maxAttempts) throw err;
148
+ await sleep(backoffMs(attempt));
149
+ }
150
+ }
151
+ }
152
+ } catch (err) {
153
+ const unpurged = unique.slice(written);
154
+ emitKvsMetrics(metrics, {
155
+ kvsArn,
156
+ metrics: { KvsPurgeFailure: 1, KvsPurgePaths: written, KvsPurgeUnpurgedPaths: unpurged.length },
157
+ unpurgedPaths: unpurged,
158
+ });
159
+ throw new KvsPurgeError(
160
+ `KVS purge stopped: ${written} of ${unique.length} paths written; `
161
+ + `${unpurged.length} ${unpurged.length === 1 ? 'path' : 'paths'} not purged`,
162
+ { unpurgedPaths: unpurged, writtenPaths: unique.slice(0, written), cause: err },
163
+ );
164
+ }
165
+
166
+ emitKvsMetrics(metrics, {
167
+ kvsArn,
168
+ metrics: { KvsPurgeFailure: 0, KvsPurgePaths: unique.length, KvsPurgeUnpurgedPaths: 0 },
169
+ unpurgedPaths: [],
170
+ });
171
+ }
172
+
173
+ /**
174
+ * A failed cache_purge must be loud and must not block the event's other
175
+ * actions: log one line, hand the error to onError, and never reject.
176
+ */
177
+ async function reportCachePurgeFailure(
178
+ action: EventAction,
179
+ err: unknown,
180
+ onError: DispatchOptions['onError'],
181
+ ): Promise<void> {
182
+ try {
183
+ if (err instanceof KvsPurgeError) {
184
+ const shown = err.unpurgedPaths.slice(0, MAX_LOGGED_UNPURGED_PATHS);
185
+ const more = err.unpurgedPaths.length - shown.length;
186
+ console.error(
187
+ `cache_purge action ${action.id}: ${err.message}. Unpurged: ${shown.join(', ')}`
188
+ + (more > 0 ? ` (+${more} more)` : ''),
189
+ );
190
+ if (onError) await onError(err);
191
+ } else {
192
+ console.error(`cache_purge action ${action.id} failed: ${(err as Error)?.message ?? String(err)}`);
193
+ }
194
+ } catch {
195
+ // A broken hook or log must not turn into a rejection on the listener.
103
196
  }
104
197
  }
105
198
 
@@ -162,8 +255,10 @@ export async function dispatchActions(
162
255
  break;
163
256
  case 'cache_purge':
164
257
  tasks.push(
165
- dispatchCachePurge(action, event, row, kvsClient, options.defaultKvsArn ?? null)
166
- .catch(() => {})
258
+ dispatchCachePurge(action, event, row, kvsClient, options.defaultKvsArn ?? null, {
259
+ metrics: options.metrics,
260
+ sleep: options.sleep,
261
+ }).catch((err) => reportCachePurgeFailure(action, err, options.onError))
167
262
  );
168
263
  break;
169
264
  case 'webhook':
package/src/index.ts CHANGED
@@ -2,6 +2,9 @@ export { createListener, createListenerInternal } from './listener';
2
2
  export { generateTriggerSQL, generateEventActionsTriggerSQL } from './triggers';
3
3
  export { matchEventActions, needsRowFetch, filterByTenant, applyFilter, resolveTemplate, createActionCache } from './actions';
4
4
  export { dispatchActions, dispatchRpc, dispatchJob, dispatchCachePurge } from './dispatchers';
5
+ export type { KvsClient, DispatchOptions, CachePurgeOptions } from './dispatchers';
6
+ export { KvsPurgeError } from './kvs-purge';
7
+ export type { KvsMetricsOptions, KvsMetricsSetting } from './kvs-metrics';
5
8
  export { createConnectHandler, createDisconnectHandler, createDefaultHandler } from './handler/index';
6
9
  export { createEventsClient, EventsProvider, useEventsClient, useSignal, useSubscription } from './client/index';
7
10
  export type {
@@ -0,0 +1,59 @@
1
+ import type { KvsClient } from './dispatchers';
2
+
3
+ /**
4
+ * Build a KVS client that signs with an explicit SigV4a signer.
5
+ *
6
+ * Duplicate of `createKvsClient` in packages/server/src/kvs.ts (events does not
7
+ * depend on @everystack/server). Keep the two in step.
8
+ *
9
+ * KVS endpoints require SigV4a. A side-effect `import('@aws-sdk/signature-v4a')`
10
+ * registers the signer into one copy of @smithy/signature-v4, but the KVS client
11
+ * can carry a nested copy whose container is empty, and every request then fails
12
+ * with "Neither CRT nor JS SigV4a implementation is available". When
13
+ * `config.signer` is set the SDK uses it directly and reads no container.
14
+ *
15
+ * The credentials provider and sha256 come from a throwaway client, so the
16
+ * signer uses the same default chain and hash the SDK would have used.
17
+ * The `as string` imports keep these optional peers out of type resolution.
18
+ */
19
+ export async function createKvsSdkClient(region?: string): Promise<any> {
20
+ const { SignatureV4a } = await import('@aws-sdk/signature-v4a' as string);
21
+ const { CloudFrontKeyValueStoreClient } = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
22
+
23
+ const { credentials, sha256 } = new CloudFrontKeyValueStoreClient({ region }).config;
24
+ const signer = new SignatureV4a({
25
+ credentials,
26
+ region: '*',
27
+ service: 'cloudfront-keyvaluestore',
28
+ sha256,
29
+ });
30
+ // Same provider on the kept client, so there is one credential chain, not two.
31
+ return new CloudFrontKeyValueStoreClient({ region, signer, credentials });
32
+ }
33
+
34
+ /**
35
+ * The `KvsClient` the cache_purge dispatcher uses, over a KVS SDK client.
36
+ * `put` returns the ETag the PutKey answer carries, so the next path in the
37
+ * same purge skips its Describe.
38
+ */
39
+ export async function kvsDispatchClient(client: any): Promise<KvsClient> {
40
+ const {
41
+ DescribeKeyValueStoreCommand,
42
+ PutKeyCommand,
43
+ } = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
44
+ return {
45
+ describe: async (kvsArn: string) => {
46
+ const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
47
+ return { ETag: desc.ETag! };
48
+ },
49
+ put: async (kvsArn: string, key: string, value: string, etag: string) => {
50
+ const out = await client.send(new PutKeyCommand({
51
+ KvsARN: kvsArn,
52
+ Key: key,
53
+ Value: value,
54
+ IfMatch: etag,
55
+ }));
56
+ return { ETag: out?.ETag };
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CloudWatch embedded metric format (EMF) lines for the cache_purge path.
3
+ *
4
+ * Mirror of packages/server/src/kvs-metrics.ts, keep in sync. events does not
5
+ * depend on @everystack/server, so the same line shape is copied here with
6
+ * `source: 'events'` and without the check metric (events has no check).
7
+ *
8
+ * A line written to stdout in Lambda becomes a CloudWatch metric with no SDK
9
+ * call and no extra IAM. It is written with `process.stdout.write`, never a
10
+ * `console` method: under the Lambda JSON log format, Lambda wraps console
11
+ * output in its own JSON event and CloudWatch does not extract metrics from
12
+ * the wrapped object.
13
+ */
14
+
15
+ export const DEFAULT_KVS_METRICS_NAMESPACE = 'Everystack/Cache';
16
+ export const MAX_KVS_METRIC_DIMENSIONS = 2;
17
+ /** The unpurgedPaths property keeps this many entries; the metric keeps the full count. */
18
+ export const MAX_LOGGED_UNPURGED_PATHS = 20;
19
+
20
+ export type KvsMetricName = 'KvsPurgeFailure' | 'KvsPurgePaths' | 'KvsPurgeUnpurgedPaths';
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
+ // mirror of packages/server/src/kvs-metrics.ts, keep in sync
50
+ const PROPERTY_NAMES = ['_aws', 'kvsArn', 'source', 'unpurgedPaths', 'requestId'];
51
+ const METRIC_NAMES: KvsMetricName[] = ['KvsPurgeFailure', 'KvsPurgePaths', 'KvsPurgeUnpurgedPaths'];
52
+ // The server's check metric stays reserved so one setting is valid in both packages.
53
+ const RESERVED = new Set<string>([...PROPERTY_NAMES, ...METRIC_NAMES, 'KvsPurgeCheckFailure']);
54
+
55
+ // mirror of packages/server/src/kvs-metrics.ts, keep in sync
56
+ /**
57
+ * Resolve the namespace and dimensions once, at call time. Returns null when
58
+ * metrics are off. Throws on more than 2 dimensions or a reserved dimension
59
+ * name; the purge catches that through resolveKvsMetricsOrDefault.
60
+ */
61
+ export function resolveKvsMetrics(
62
+ setting?: KvsMetricsSetting,
63
+ env: NodeJS.ProcessEnv = process.env,
64
+ ): ResolvedKvsMetrics | null {
65
+ if (setting === false) return null;
66
+ const options = setting ?? {};
67
+
68
+ let dimensions: Record<string, string>;
69
+ if (options.dimensions) {
70
+ const names = Object.keys(options.dimensions);
71
+ if (names.length > MAX_KVS_METRIC_DIMENSIONS) {
72
+ throw new Error(
73
+ `KVS metrics: at most ${MAX_KVS_METRIC_DIMENSIONS} dimensions, got ${names.length} `
74
+ + `(${names.join(', ')}). Put high-cardinality values in properties, not dimensions.`,
75
+ );
76
+ }
77
+ const reserved = names.filter((n) => RESERVED.has(n));
78
+ if (reserved.length > 0) {
79
+ throw new Error(
80
+ `KVS metrics: dimension name ${reserved.join(', ')} is reserved for a property or metric.`,
81
+ );
82
+ }
83
+ dimensions = { ...options.dimensions };
84
+ } else {
85
+ const fn = env.AWS_LAMBDA_FUNCTION_NAME;
86
+ dimensions = fn ? { FunctionName: fn } : {};
87
+ }
88
+
89
+ return {
90
+ namespace: options.namespace ?? DEFAULT_KVS_METRICS_NAMESPACE,
91
+ dimensions,
92
+ ...(options.requestId ? { requestId: options.requestId } : {}),
93
+ };
94
+ }
95
+
96
+ // mirror of packages/server/src/kvs-metrics.ts, keep in sync
97
+ /**
98
+ * Resolve the setting for a purge. A bad setting must never stop a purge
99
+ * (stale beats down): log one error line naming the problem and fall back to
100
+ * the default metrics.
101
+ */
102
+ export function resolveKvsMetricsOrDefault(
103
+ setting?: KvsMetricsSetting,
104
+ env: NodeJS.ProcessEnv = process.env,
105
+ ): ResolvedKvsMetrics | null {
106
+ try {
107
+ return resolveKvsMetrics(setting, env);
108
+ } catch (err) {
109
+ console.error(`KVS metrics setting ignored, using the defaults: ${(err as Error).message}`);
110
+ return resolveKvsMetrics(undefined, env);
111
+ }
112
+ }
113
+
114
+ // mirror of packages/server/src/kvs-metrics.ts, keep in sync
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: 'events',
135
+ ...(point.unpurgedPaths
136
+ ? { unpurgedPaths: point.unpurgedPaths.slice(0, MAX_LOGGED_UNPURGED_PATHS) }
137
+ : {}),
138
+ ...(resolved.requestId ? { requestId: resolved.requestId } : {}),
139
+ };
140
+ }
141
+
142
+ // mirror of packages/server/src/kvs-metrics.ts, keep in sync
143
+ /**
144
+ * Write one EMF line to stdout. Never throws: a metric that cannot be written
145
+ * must not turn a purge, or a purge failure, into a different error.
146
+ */
147
+ export function emitKvsMetrics(resolved: ResolvedKvsMetrics | null, point: KvsMetricPoint): void {
148
+ if (!resolved) return;
149
+ try {
150
+ process.stdout.write(JSON.stringify(buildKvsEmf(resolved, point)) + '\n');
151
+ } catch {
152
+ // Nothing to do: the metric is lost, the caller's outcome is not.
153
+ }
154
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The retry rules and the error type of a KVS purge.
3
+ *
4
+ * events does not depend on @everystack/server, so these are copied from
5
+ * packages/server/src/kvs.ts. The error has the same name and shape there and
6
+ * here, so one consumer guard (`err.name === 'KvsPurgeError'`) covers both.
7
+ */
8
+
9
+ // mirror of packages/server/src/kvs.ts, keep in sync
10
+ export const DEFAULT_MAX_ATTEMPTS = 5;
11
+ const BACKOFF_BASE_MS = 25;
12
+ const BACKOFF_CAP_MS = 400;
13
+
14
+ // mirror of packages/server/src/kvs.ts, keep in sync
15
+ /** A purge that stopped part way. `unpurgedPaths` are the keys NOT written. */
16
+ export class KvsPurgeError extends Error {
17
+ readonly unpurgedPaths: string[];
18
+ readonly writtenPaths: string[];
19
+
20
+ constructor(
21
+ message: string,
22
+ details: { unpurgedPaths: string[]; writtenPaths: string[]; cause: unknown },
23
+ ) {
24
+ super(message, { cause: details.cause });
25
+ this.name = 'KvsPurgeError';
26
+ this.unpurgedPaths = details.unpurgedPaths;
27
+ this.writtenPaths = details.writtenPaths;
28
+ }
29
+ }
30
+
31
+ // mirror of packages/server/src/kvs.ts, keep in sync
32
+ /**
33
+ * A write refused because another writer moved the ETag first. The KVS API
34
+ * models this as ConflictException (409); 412 PreconditionFailed is matched
35
+ * too in case the service ever answers with the HTTP precondition status.
36
+ */
37
+ export function isEtagConflict(err: unknown): boolean {
38
+ const e = err as { name?: string; $metadata?: { httpStatusCode?: number } } | null;
39
+ const status = e?.$metadata?.httpStatusCode;
40
+ return status === 409 || status === 412
41
+ || e?.name === 'ConflictException'
42
+ || e?.name === 'PreconditionFailed'
43
+ || e?.name === 'PreconditionFailedException';
44
+ }
45
+
46
+ // mirror of packages/server/src/kvs.ts, keep in sync
47
+ export function backoffMs(attempt: number): number {
48
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
49
+ return ceiling / 2 + Math.random() * (ceiling / 2);
50
+ }
51
+
52
+ export const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
package/src/listener.ts CHANGED
@@ -2,6 +2,7 @@ import type { EventPayload, EventAction, EventActionsOptions, ListenerOptions, L
2
2
  import { matchEventActions, createActionCache } from './actions';
3
3
  import { dispatchActions } from './dispatchers';
4
4
  import type { KvsClient } from './dispatchers';
5
+ import { createKvsSdkClient, kvsDispatchClient } from './kvs-client';
5
6
 
6
7
  // --- Subscription state (in-memory) ---
7
8
 
@@ -358,6 +359,8 @@ export function createListenerInternal(options: InternalListenerOptions) {
358
359
  dispatchActions(matched, ev, pgClient, lambdaClient, kvsClient, {
359
360
  workerFunctionName: eventActions?.workerFunctionName,
360
361
  defaultKvsArn: eventActions?.defaultKvsArn,
362
+ onError: eventActions?.onError,
363
+ metrics: eventActions?.metrics,
361
364
  });
362
365
  }
363
366
  }
@@ -430,27 +433,7 @@ export function createListener(options: ListenerOptions) {
430
433
  let kvsClient: KvsClient | null = null;
431
434
  if (options.eventActions?.defaultKvsArn) {
432
435
  try {
433
- await import('@aws-sdk/signature-v4a' as string);
434
- const {
435
- CloudFrontKeyValueStoreClient,
436
- DescribeKeyValueStoreCommand,
437
- PutKeyCommand,
438
- } = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
439
- const client = new CloudFrontKeyValueStoreClient({});
440
- kvsClient = {
441
- describe: async (kvsArn: string) => {
442
- const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
443
- return { ETag: desc.ETag! };
444
- },
445
- put: async (kvsArn: string, key: string, value: string, etag: string) => {
446
- await client.send(new PutKeyCommand({
447
- KvsARN: kvsArn,
448
- Key: key,
449
- Value: value,
450
- IfMatch: etag,
451
- }));
452
- },
453
- };
436
+ kvsClient = await kvsDispatchClient(await createKvsSdkClient());
454
437
  } catch {
455
438
  // KVS SDK not available — skip cache_purge actions
456
439
  }
package/src/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { KvsMetricsSetting } from './kvs-metrics';
2
+ import type { KvsPurgeError } from './kvs-purge';
3
+
1
4
  // --- Core ---
2
5
 
3
6
  export interface EventPayload {
@@ -33,6 +36,10 @@ export interface ListenerRoutes {
33
36
  export interface EventActionsOptions {
34
37
  workerFunctionName?: string;
35
38
  defaultKvsArn?: string | null;
39
+ /** Called once per failed cache_purge. A throw or rejection is ignored. */
40
+ onError?: (error: KvsPurgeError) => void | Promise<void>;
41
+ /** One EMF line per cache_purge dispatch (`source: 'events'`). `false` turns it off. */
42
+ metrics?: KvsMetricsSetting;
36
43
  }
37
44
 
38
45
  export interface ListenerOptions {