@everystack/events 0.4.2 → 0.4.4

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,20 @@ 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.4] - 2026-10-30
8
+
9
+ ### Added
10
+
11
+ - createListener takes a role and switches to it once per session
12
+
13
+ ## [0.4.3] - 2026-10-23
14
+
15
+ ### Fixed
16
+
17
+ - sign KVS purges with an explicit SigV4a signer
18
+ - KVS client shares the signer's credentials; prove the send path signs
19
+ - cache_purge retries ETag conflicts and fails loudly
20
+
7
21
  ## [0.4.2] - 2026-10-15
8
22
 
9
23
  _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.4",
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.25"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@aws-sdk/client-apigatewaymanagementapi": "3.1053.0",
@@ -105,10 +105,11 @@
105
105
  "@types/react": "19.2.14",
106
106
  "drizzle-orm": "0.41.0",
107
107
  "jest": "29.7.0",
108
+ "postgres": "3.4.9",
108
109
  "react": "19.2.0",
109
110
  "ts-jest": "29.4.9",
110
111
  "typescript": "5.9.3",
111
- "@everystack/auth": "0.4.9"
112
+ "@everystack/auth": "0.4.10"
112
113
  },
113
114
  "scripts": {
114
115
  "test": "jest",
@@ -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
 
@@ -275,6 +276,26 @@ async function reconcile(
275
276
  await loadEventActions(pgClient, actionCache);
276
277
  }
277
278
 
279
+ // --- Session role ---
280
+
281
+ const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
282
+
283
+ /** The role is spliced into SET ROLE, so it must be a plain identifier; never the lifecycle role. */
284
+ function assertListenerRole(role: string): void {
285
+ if (!ROLE_NAME.test(role)) throw new Error(`Invalid role name: ${JSON.stringify(role)}`);
286
+ if (role === 'everystack_lifecycle') throw new Error('the listener must not run as everystack_lifecycle: pass an application role (e.g. admin)');
287
+ }
288
+
289
+ /**
290
+ * Switch the session to `role` and prove it took. Session-level, not SET LOCAL: LISTEN is
291
+ * session-scoped and the listener holds one session for its whole run, outside any transaction.
292
+ */
293
+ async function switchSessionRole(pgClient: InternalListenerOptions['pgClient'], role: string): Promise<void> {
294
+ await pgClient.query(`SET ROLE ${role}`);
295
+ const { rows } = await pgClient.query('SELECT current_user AS role');
296
+ if (rows[0]?.role !== role) throw new Error(`role ${role} was not set: current_user is ${String(rows[0]?.role)}`);
297
+ }
298
+
278
299
  // --- Internal (testable) listener factory ---
279
300
 
280
301
  interface InternalListenerOptions {
@@ -293,6 +314,8 @@ interface InternalListenerOptions {
293
314
  heartbeatInterval?: number;
294
315
  eventActions?: EventActionsOptions;
295
316
  kvsClient?: KvsClient | null;
317
+ /** See ListenerOptions.role. */
318
+ role?: string;
296
319
  }
297
320
 
298
321
  interface HandlerEvent {
@@ -310,11 +333,22 @@ export function createListenerInternal(options: InternalListenerOptions) {
310
333
  heartbeatInterval = 30000,
311
334
  eventActions,
312
335
  kvsClient = null,
336
+ role,
313
337
  } = options;
338
+ if (role !== undefined) assertListenerRole(role);
314
339
 
315
340
  return async function handler(event: HandlerEvent = {}): Promise<{ status: string; reason?: string }> {
316
- // 1. Connect
341
+ // 1. Connect, then switch role before anything else runs in this session. This is the only
342
+ // connect: the session is never re-opened, so the switch covers everything after it.
317
343
  await pgClient.connect();
344
+ if (role !== undefined) {
345
+ try {
346
+ await switchSessionRole(pgClient, role);
347
+ } catch (err) {
348
+ await pgClient.end().catch(() => {});
349
+ throw err;
350
+ }
351
+ }
318
352
 
319
353
  // 2. Acquire advisory lock
320
354
  const { rows } = await pgClient.query(
@@ -358,6 +392,8 @@ export function createListenerInternal(options: InternalListenerOptions) {
358
392
  dispatchActions(matched, ev, pgClient, lambdaClient, kvsClient, {
359
393
  workerFunctionName: eventActions?.workerFunctionName,
360
394
  defaultKvsArn: eventActions?.defaultKvsArn,
395
+ onError: eventActions?.onError,
396
+ metrics: eventActions?.metrics,
361
397
  });
362
398
  }
363
399
  }
@@ -390,6 +426,7 @@ export function createListenerInternal(options: InternalListenerOptions) {
390
426
  // --- Public API ---
391
427
 
392
428
  export function createListener(options: ListenerOptions) {
429
+ if (options.role !== undefined) assertListenerRole(options.role);
393
430
  return async function handler(_event?: unknown): Promise<{ status: string; reason?: string }> {
394
431
  // Dynamic imports to avoid bundling AWS SDK when not needed
395
432
  const { default: pg } = await import('pg' as string);
@@ -430,27 +467,7 @@ export function createListener(options: ListenerOptions) {
430
467
  let kvsClient: KvsClient | null = null;
431
468
  if (options.eventActions?.defaultKvsArn) {
432
469
  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
- };
470
+ kvsClient = await kvsDispatchClient(await createKvsSdkClient());
454
471
  } catch {
455
472
  // KVS SDK not available — skip cache_purge actions
456
473
  }
@@ -466,6 +483,7 @@ export function createListener(options: ListenerOptions) {
466
483
  heartbeatInterval: options.heartbeatInterval,
467
484
  eventActions: options.eventActions,
468
485
  kvsClient,
486
+ role: options.role,
469
487
  });
470
488
 
471
489
  return internal({});
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 {
@@ -43,6 +50,12 @@ export interface ListenerOptions {
43
50
  reconcile?: (db: unknown) => Promise<void>;
44
51
  heartbeatInterval?: number;
45
52
  eventActions?: EventActionsOptions;
53
+ /**
54
+ * An application role to `SET ROLE` to right after connect, checked with `current_user`.
55
+ * Lets the listener connect as a least-privilege login (NOINHERIT, granted the role) instead
56
+ * of the owner. Each invocation opens a new session and switches it. Omit to stay as the login.
57
+ */
58
+ role?: string;
46
59
  }
47
60
 
48
61
  // --- Subscription (internal NOTIFY payloads) ---