@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 +14 -0
- package/package.json +4 -3
- package/src/dispatchers.ts +107 -12
- package/src/index.ts +3 -0
- package/src/kvs-client.ts +59 -0
- package/src/kvs-metrics.ts +154 -0
- package/src/kvs-purge.ts +52 -0
- package/src/listener.ts +40 -22
- package/src/types.ts +13 -0
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.
|
|
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.
|
|
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.
|
|
112
|
+
"@everystack/auth": "0.4.10"
|
|
112
113
|
},
|
|
113
114
|
"scripts": {
|
|
114
115
|
"test": "jest",
|
package/src/dispatchers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
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
|
-
.
|
|
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
|
+
}
|
package/src/kvs-purge.ts
ADDED
|
@@ -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
|
|
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) ---
|