@layered-loader/sqs 1.0.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +433 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.js +13 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/SqsGroupNotificationConsumer.d.ts +28 -0
  7. package/dist/lib/SqsGroupNotificationConsumer.js +107 -0
  8. package/dist/lib/SqsGroupNotificationConsumer.js.map +1 -0
  9. package/dist/lib/SqsGroupNotificationFactory.d.ts +22 -0
  10. package/dist/lib/SqsGroupNotificationFactory.js +40 -0
  11. package/dist/lib/SqsGroupNotificationFactory.js.map +1 -0
  12. package/dist/lib/SqsGroupNotificationPublisher.d.ts +38 -0
  13. package/dist/lib/SqsGroupNotificationPublisher.js +102 -0
  14. package/dist/lib/SqsGroupNotificationPublisher.js.map +1 -0
  15. package/dist/lib/SqsNotificationConsumer.d.ts +44 -0
  16. package/dist/lib/SqsNotificationConsumer.js +123 -0
  17. package/dist/lib/SqsNotificationConsumer.js.map +1 -0
  18. package/dist/lib/SqsNotificationFactory.d.ts +29 -0
  19. package/dist/lib/SqsNotificationFactory.js +40 -0
  20. package/dist/lib/SqsNotificationFactory.js.map +1 -0
  21. package/dist/lib/SqsNotificationPublisher.d.ts +39 -0
  22. package/dist/lib/SqsNotificationPublisher.js +109 -0
  23. package/dist/lib/SqsNotificationPublisher.js.map +1 -0
  24. package/dist/lib/channelNameResolver.d.ts +12 -0
  25. package/dist/lib/channelNameResolver.js +18 -0
  26. package/dist/lib/channelNameResolver.js.map +1 -0
  27. package/dist/lib/groupNotificationSchemas.d.ts +49 -0
  28. package/dist/lib/groupNotificationSchemas.js +31 -0
  29. package/dist/lib/groupNotificationSchemas.js.map +1 -0
  30. package/dist/lib/notificationSchemas.d.ts +66 -0
  31. package/dist/lib/notificationSchemas.js +40 -0
  32. package/dist/lib/notificationSchemas.js.map +1 -0
  33. package/dist/lib/triggers/AbstractSqsTrigger.d.ts +62 -0
  34. package/dist/lib/triggers/AbstractSqsTrigger.js +104 -0
  35. package/dist/lib/triggers/AbstractSqsTrigger.js.map +1 -0
  36. package/dist/lib/triggers/SqsGroupInvalidationTrigger.d.ts +39 -0
  37. package/dist/lib/triggers/SqsGroupInvalidationTrigger.js +46 -0
  38. package/dist/lib/triggers/SqsGroupInvalidationTrigger.js.map +1 -0
  39. package/dist/lib/triggers/SqsInvalidationTrigger.d.ts +56 -0
  40. package/dist/lib/triggers/SqsInvalidationTrigger.js +47 -0
  41. package/dist/lib/triggers/SqsInvalidationTrigger.js.map +1 -0
  42. package/dist/lib/triggers/dispatch.d.ts +19 -0
  43. package/dist/lib/triggers/dispatch.js +69 -0
  44. package/dist/lib/triggers/dispatch.js.map +1 -0
  45. package/dist/lib/triggers/types.d.ts +54 -0
  46. package/dist/lib/triggers/types.js +12 -0
  47. package/dist/lib/triggers/types.js.map +1 -0
  48. package/package.json +75 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2024 Igor Savin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,433 @@
1
+ # @layered-loader/sqs
2
+
3
+ SNS/SQS remote-invalidation adapter for [`layered-loader`](https://github.com/kibertoad/layered-loader).
4
+
5
+ This package provides:
6
+
7
+ - **Notification publishers and consumers** that fan cache invalidations out across a cluster via an SNS topic and per-instance SQS queues — a drop-in alternative to the built-in Redis adapter for AWS-native deployments.
8
+ - **Flexible invalidation triggers** that subscribe to an *existing* upstream SNS topic or SQS queue (one that knows nothing about the caching layer) and translate domain events such as `user.updated` into cache invalidations propagated through your notification pair.
9
+
10
+ The implementation is built on top of [`@message-queue-toolkit/sns`](https://github.com/kibertoad/message-queue-toolkit) and is tested against [fauxqs](https://github.com/kibertoad/fauxqs), an in-process SNS/SQS emulator.
11
+
12
+ ## Contents
13
+
14
+ - [Installation](#installation)
15
+ - [Quick start: notification pair](#quick-start-notification-pair)
16
+ - [Group notification pair](#group-notification-pair)
17
+ - [Locator vs creation config](#locator-vs-creation-config)
18
+ - [How invalidation flows through SNS/SQS](#how-invalidation-flows-through-snssqs)
19
+ - [Self-message filtering and `serverUuid`](#self-message-filtering-and-serveruuid)
20
+ - [Flexible invalidation triggers](#flexible-invalidation-triggers)
21
+ - [Triggering from an existing SNS topic](#triggering-from-an-existing-sns-topic)
22
+ - [Triggering from an existing SQS queue](#triggering-from-an-existing-sqs-queue)
23
+ - [Group triggers](#group-triggers)
24
+ - [Resolver semantics](#resolver-semantics)
25
+ - [The trigger-publisher rule](#the-trigger-publisher-rule)
26
+ - [Error handling and retries](#error-handling-and-retries)
27
+ - [Testing with fauxqs](#testing-with-fauxqs)
28
+ - [API reference](#api-reference)
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install @layered-loader/sqs layered-loader
34
+ # Plus the AWS SDK clients and message-queue-toolkit (peer deps):
35
+ npm install \
36
+ @aws-sdk/client-sns @aws-sdk/client-sqs @aws-sdk/client-sts \
37
+ @lokalise/node-core \
38
+ @message-queue-toolkit/core @message-queue-toolkit/sns @message-queue-toolkit/sqs \
39
+ zod
40
+ ```
41
+
42
+ Node 20+ is required.
43
+
44
+ ## Quick start: notification pair
45
+
46
+ The simplest setup mirrors the built-in Redis pair: each application instance gets a `publisher` (sends invalidations to a shared SNS topic) and a `consumer` (reads its own SQS queue subscribed to that topic and applies invalidations to its in-memory cache).
47
+
48
+ ```ts
49
+ import { SNSClient } from '@aws-sdk/client-sns'
50
+ import { SQSClient } from '@aws-sdk/client-sqs'
51
+ import { STSClient } from '@aws-sdk/client-sts'
52
+ import { globalLogger, NoopObservabilityManager } from '@lokalise/node-core'
53
+ import { SnsConsumerErrorResolver } from '@message-queue-toolkit/sns'
54
+ import { Loader } from 'layered-loader'
55
+ import { createNotificationPair } from '@layered-loader/sqs'
56
+ import type { User } from './types'
57
+
58
+ const region = 'us-east-1'
59
+ const snsClient = new SNSClient({ region })
60
+ const sqsClient = new SQSClient({ region })
61
+ const stsClient = new STSClient({ region })
62
+
63
+ const errorReporter = { report: () => {} }
64
+
65
+ const { publisher: notificationPublisher, consumer: notificationConsumer } =
66
+ createNotificationPair<User>({
67
+ publisher: {
68
+ dependencies: { snsClient, stsClient, logger: globalLogger, errorReporter },
69
+ creationConfig: { topic: { Name: 'user-cache-invalidations' } },
70
+ },
71
+ consumer: {
72
+ dependencies: {
73
+ snsClient,
74
+ sqsClient,
75
+ stsClient,
76
+ logger: globalLogger,
77
+ errorReporter,
78
+ consumerErrorResolver: new SnsConsumerErrorResolver(),
79
+ transactionObservabilityManager: new NoopObservabilityManager(),
80
+ },
81
+ creationConfig: {
82
+ topic: { Name: 'user-cache-invalidations' },
83
+ // Each instance MUST use a unique queue name (e.g. include the host id):
84
+ queue: { QueueName: `user-cache-invalidations-${process.env.HOSTNAME}` },
85
+ },
86
+ },
87
+ })
88
+
89
+ const userLoader = new Loader<User>({
90
+ inMemoryCache: { ttlInMsecs: 1000 * 60 * 5 },
91
+ asyncCache: yourAsyncCache,
92
+ notificationConsumer,
93
+ notificationPublisher,
94
+ })
95
+
96
+ await userLoader.init()
97
+ await userLoader.invalidateCacheFor('123') // fans out to every other instance
98
+ ```
99
+
100
+ Each consumer needs its **own** SQS queue subscribed to the shared topic; SNS handles the fan-out. If two instances share a queue, only one of them will receive each invalidation message.
101
+
102
+ ## Group notification pair
103
+
104
+ For `GroupLoader`, use `createGroupNotificationPair` with the same shape:
105
+
106
+ ```ts
107
+ import { GroupLoader } from 'layered-loader'
108
+ import { createGroupNotificationPair } from '@layered-loader/sqs'
109
+
110
+ const { publisher: notificationPublisher, consumer: notificationConsumer } =
111
+ createGroupNotificationPair<User>({
112
+ publisher: { dependencies, creationConfig: { topic: { Name: 'tenant-cache-invalidations' } } },
113
+ consumer: {
114
+ dependencies: consumerDependencies,
115
+ creationConfig: {
116
+ topic: { Name: 'tenant-cache-invalidations' },
117
+ queue: { QueueName: `tenant-cache-invalidations-${process.env.HOSTNAME}` },
118
+ },
119
+ },
120
+ })
121
+
122
+ const userLoader = new GroupLoader<User>({
123
+ inMemoryCache: { ttlInMsecs: 1000 * 60 * 5 },
124
+ asyncCache: yourAsyncCache,
125
+ notificationConsumer,
126
+ notificationPublisher,
127
+ })
128
+
129
+ await userLoader.invalidateCacheFor('user-1', 'tenant-A')
130
+ ```
131
+
132
+ ## Locator vs creation config
133
+
134
+ Both publisher and consumer accept a discriminated config:
135
+
136
+ | Field | Behaviour |
137
+ | --- | --- |
138
+ | `creationConfig` | Auto-creates the resource (`topic`, `queue`, `subscription`) on `subscribe()` if it does not exist. |
139
+ | `locatorConfig` | Reuses pre-provisioned resources. Throws if they are missing. |
140
+
141
+ For a consumer in `locatorConfig` mode, you must supply enough information to resolve the topic, queue, and subscription:
142
+
143
+ ```ts
144
+ consumer: {
145
+ dependencies: consumerDependencies,
146
+ locatorConfig: {
147
+ topicArn: 'arn:aws:sns:us-east-1:000000000000:user-cache-invalidations',
148
+ queueUrl: 'https://sqs.us-east-1.amazonaws.com/000000000000/cache-q-host-1',
149
+ subscriptionArn: 'arn:aws:sns:...:subscription/...',
150
+ },
151
+ }
152
+ ```
153
+
154
+ You can grab those identifiers off a previously-initialised consumer/publisher:
155
+
156
+ ```ts
157
+ await pair.publisher.subscribe()
158
+ await pair.consumer.subscribe()
159
+ console.log(pair.publisher.topicArn)
160
+ console.log(pair.consumer.subscriptionArn, pair.consumer.queueUrl)
161
+ ```
162
+
163
+ If you need to override defaults of the SNS subscription (filter policy, raw delivery, etc.), pass `subscriptionConfig: SqsSubscriptionOptions` to the consumer config.
164
+
165
+ ## How invalidation flows through SNS/SQS
166
+
167
+ ```
168
+ ┌──────────────────┐ SNS topic ┌──────────────────┐
169
+ │ Instance A │ publisher ────────────────────────────▶ ┌──── │ Instance B │
170
+ │ Loader │ │ ───┴── consumer.delete(key)
171
+ │ │ consumer ──── (own queue, self-skip) │
172
+ └──────────────────┘ │ ───┬── consumer.delete(key)
173
+ └──── │ Instance C │
174
+ └──────────────────┘
175
+ ```
176
+
177
+ Each `Loader.invalidateCacheFor(...)` call publishes a JSON command (`DELETE`, `DELETE_MANY`, `SET`, `CLEAR`) to the SNS topic. SNS fan-outs to every subscribed SQS queue, and each consumer applies the command to its local in-memory cache.
178
+
179
+ ## Self-message filtering and `serverUuid`
180
+
181
+ Every published command carries an `originUuid`. A consumer skips a command whose `originUuid` matches its own `serverUuid`, preventing instance A from re-applying its own invalidations bouncing back through SNS.
182
+
183
+ `createNotificationPair` (and `createGroupNotificationPair`) generate one `serverUuid` shared by both the publisher and the consumer it returns. You can override it with the `serverUuid` field if you need stable identifiers across restarts (e.g. when locating an existing subscription).
184
+
185
+ ## Flexible invalidation triggers
186
+
187
+ A *trigger* lets you treat any upstream messaging system as a source of cache-invalidation events without that system knowing the cache exists. The trigger:
188
+
189
+ 1. Subscribes to a queue or topic you do not own.
190
+ 2. Validates each message with a Zod schema.
191
+ 3. Runs your **resolver** to extract entity ids (and optionally a group).
192
+ 4. Forwards the resulting actions through a configured `NotificationPublisher`, fanning them out to every cache instance.
193
+
194
+ The actions and resolver shape are transport-agnostic — the same `InvalidationResolver`, `InvalidationAction`, dispatch helpers, and `InvalidationTrigger` interface can power future RabbitMQ / Kafka / Pub/Sub adapters. The SNS/SQS adapters live in this package.
195
+
196
+ ### Triggering from an existing SNS topic
197
+
198
+ Use `sourceType: 'sns-topic'` with either creation or locator config. The trigger creates (or reuses) an SQS queue and subscribes it to the upstream topic.
199
+
200
+ ```ts
201
+ import { randomUUID } from 'node:crypto'
202
+ import { z } from 'zod'
203
+ import {
204
+ createNotificationPair,
205
+ SqsInvalidationTrigger,
206
+ SqsNotificationPublisher,
207
+ } from '@layered-loader/sqs'
208
+
209
+ const USER_EVENT_SCHEMA = z.object({
210
+ type: z.enum(['user.updated', 'user.deleted', 'user.bulk-updated']),
211
+ userId: z.string().optional(),
212
+ userIds: z.array(z.string()).optional(),
213
+ })
214
+
215
+ // 1. The cache cluster's own invalidation pair (same as any deployment):
216
+ const cachePair = createNotificationPair<User>({
217
+ publisher: { dependencies: pubDeps, creationConfig: { topic: { Name: 'user-cache-invalidations' } } },
218
+ consumer: {
219
+ dependencies: consumerDeps,
220
+ creationConfig: {
221
+ topic: { Name: 'user-cache-invalidations' },
222
+ queue: { QueueName: `user-cache-invalidations-${process.env.HOSTNAME}` },
223
+ },
224
+ },
225
+ })
226
+
227
+ // 2. A separate publisher dedicated to trigger-emitted messages.
228
+ // Its serverUuid MUST be different from cachePair's so the local consumer
229
+ // treats trigger messages as foreign and applies them.
230
+ const triggerPublisher = new SqsNotificationPublisher<User>({
231
+ serverUuid: randomUUID(),
232
+ dependencies: pubDeps,
233
+ locatorConfig: { topicName: 'user-cache-invalidations' },
234
+ })
235
+
236
+ // 3. The trigger itself, subscribed to an upstream domain-event topic
237
+ // owned by another service:
238
+ const trigger = new SqsInvalidationTrigger<z.infer<typeof USER_EVENT_SCHEMA>>({
239
+ sourceType: 'sns-topic',
240
+ dependencies: consumerDeps,
241
+ creationConfig: {
242
+ topic: { Name: 'domain-events.users' }, // owned by an upstream service
243
+ queue: { QueueName: `cache-trigger-${process.env.HOSTNAME}` },
244
+ },
245
+ messageSchema: USER_EVENT_SCHEMA,
246
+ publisher: triggerPublisher,
247
+ resolver: (msg) => {
248
+ switch (msg.type) {
249
+ case 'user.updated':
250
+ case 'user.deleted':
251
+ return msg.userId ? { kind: 'delete', key: msg.userId } : null
252
+ case 'user.bulk-updated':
253
+ return msg.userIds?.length
254
+ ? { kind: 'deleteMany', keys: msg.userIds }
255
+ : null
256
+ }
257
+ },
258
+ })
259
+
260
+ await trigger.start()
261
+ ```
262
+
263
+ ### Triggering from an existing SQS queue
264
+
265
+ If the upstream system writes directly to an SQS queue (no SNS topic in the middle), use `sourceType: 'sqs-queue'`:
266
+
267
+ ```ts
268
+ import { SqsInvalidationTrigger } from '@layered-loader/sqs'
269
+
270
+ const trigger = new SqsInvalidationTrigger<DomainEvent>({
271
+ sourceType: 'sqs-queue',
272
+ dependencies: sqsConsumerDeps,
273
+ locatorConfig: {
274
+ queueUrl: 'https://sqs.us-east-1.amazonaws.com/000000000000/domain-events',
275
+ },
276
+ messageSchema: DOMAIN_EVENT_SCHEMA,
277
+ publisher: triggerPublisher,
278
+ resolver: (msg) => /* ... */,
279
+ })
280
+
281
+ await trigger.start()
282
+ ```
283
+
284
+ The pure-SQS source uses `@message-queue-toolkit/sqs`'s consumer directly and does not require SNS / STS clients in its dependencies.
285
+
286
+ ### Group triggers
287
+
288
+ `SqsGroupInvalidationTrigger` mirrors the flat trigger but emits `GroupInvalidationAction`s:
289
+
290
+ ```ts
291
+ import { SqsGroupInvalidationTrigger, SqsGroupNotificationPublisher } from '@layered-loader/sqs'
292
+
293
+ const triggerPublisher = new SqsGroupNotificationPublisher<User>({
294
+ serverUuid: randomUUID(),
295
+ dependencies: pubDeps,
296
+ locatorConfig: { topicName: 'tenant-cache-invalidations' },
297
+ })
298
+
299
+ const trigger = new SqsGroupInvalidationTrigger<TenantEvent>({
300
+ sourceType: 'sns-topic',
301
+ dependencies: consumerDeps,
302
+ creationConfig: {
303
+ topic: { Name: 'tenant-events' },
304
+ queue: { QueueName: `tenant-trigger-${process.env.HOSTNAME}` },
305
+ },
306
+ messageSchema: TENANT_EVENT_SCHEMA,
307
+ publisher: triggerPublisher,
308
+ resolver: (msg) => {
309
+ if (msg.type === 'tenant.purged') return { kind: 'deleteGroup', group: msg.tenantId }
310
+ if (msg.type === 'tenant.user.updated' && msg.userId) {
311
+ return { kind: 'deleteFromGroup', key: msg.userId, group: msg.tenantId }
312
+ }
313
+ return null
314
+ },
315
+ })
316
+
317
+ await trigger.start()
318
+ ```
319
+
320
+ ### Resolver semantics
321
+
322
+ A resolver receives the validated `TMessage` and returns:
323
+
324
+ - A single `InvalidationAction` / `GroupInvalidationAction` — applied immediately.
325
+ - An array of actions — applied sequentially, preserving emission order.
326
+ - `null` or `undefined` — skip the message (the source treats it as successfully processed).
327
+
328
+ Flat actions:
329
+
330
+ ```ts
331
+ type InvalidationAction =
332
+ | { kind: 'delete'; key: string }
333
+ | { kind: 'deleteMany'; keys: readonly string[] }
334
+ | { kind: 'set'; key: string; value: unknown }
335
+ | { kind: 'clear' }
336
+ ```
337
+
338
+ Group actions:
339
+
340
+ ```ts
341
+ type GroupInvalidationAction =
342
+ | { kind: 'deleteFromGroup'; key: string; group: string }
343
+ | { kind: 'deleteGroup'; group: string }
344
+ | { kind: 'clear' }
345
+ ```
346
+
347
+ Resolvers may be `async`; the trigger awaits before publishing.
348
+
349
+ ### The trigger-publisher rule
350
+
351
+ > **The trigger's publisher MUST have a `serverUuid` distinct from any local notification pair.**
352
+
353
+ Why: a `Loader` invalidates its own in-memory cache *before* publishing, so the pair's consumer is configured to skip messages with a matching `originUuid` (otherwise the pair would re-process its own invalidations). Trigger-emitted invalidations come from outside any Loader, so the pair's consumer must treat them as foreign and apply them. Sharing `serverUuid` means the local in-memory cache silently misses every trigger-driven invalidation.
354
+
355
+ In practice: build the trigger publisher with `randomUUID()` even when it points at the same SNS topic as your `createNotificationPair` publisher. The example snippets above all show this pattern.
356
+
357
+ ### Error handling and retries
358
+
359
+ If the resolver or publish step throws, the trigger:
360
+
361
+ 1. Invokes the optional `errorHandler(err, channel)` for observability.
362
+ 2. Re-throws so `message-queue-toolkit` can apply its standard SQS retry / dead-letter behaviour.
363
+
364
+ For schema-violation errors, the message is failed by `message-queue-toolkit` before the resolver runs and goes back to the queue (and ultimately to a DLQ if you configured one). Configure DLQ on the trigger's queue creation config to bound retries.
365
+
366
+ ### Lifecycle
367
+
368
+ ```ts
369
+ const trigger = new SqsInvalidationTrigger({ ... })
370
+
371
+ await trigger.start() // idempotent; concurrent calls share one start
372
+ await trigger.stop() // idempotent; awaits any in-flight start
373
+ await trigger.start() // restart is supported
374
+ ```
375
+
376
+ ## Testing with fauxqs
377
+
378
+ For local development and tests, swap the AWS SDK endpoint for [fauxqs](https://github.com/kibertoad/fauxqs). Two modes are useful in practice:
379
+
380
+ ### In-process (recommended for tests)
381
+
382
+ Sub-second startup, no daemon required, isolated per test process. This package's own integration tests run this way — see `packages/sqs/test/globalSetup.ts`.
383
+
384
+ ```ts
385
+ import { startFauxqs } from 'fauxqs'
386
+ import { SQSClient } from '@aws-sdk/client-sqs'
387
+
388
+ const server = await startFauxqs({ port: 0, logger: false })
389
+ const credentials = { accessKeyId: 'test', secretAccessKey: 'test' }
390
+ const region = 'us-east-1'
391
+
392
+ const sqsClient = new SQSClient({ endpoint: server.address, region, credentials })
393
+ // ...
394
+ await server.stop()
395
+ ```
396
+
397
+ ### Docker-compose (for app smoke testing)
398
+
399
+ The repository root's `docker-compose.yml` includes a `fauxqs` service on port `4566` for local app runs against a stable endpoint:
400
+
401
+ ```bash
402
+ docker compose up fauxqs
403
+ ```
404
+
405
+ Then point your AWS SDK clients at `http://localhost:4566`.
406
+
407
+ ## API reference
408
+
409
+ ### Notification pair
410
+
411
+ | Symbol | Purpose |
412
+ | --- | --- |
413
+ | `createNotificationPair<T>(config)` | Returns `{ publisher, consumer }` for flat-cache invalidation. |
414
+ | `createGroupNotificationPair<T>(config)` | Same, but for group caches. |
415
+ | `SqsNotificationPublisher<T>` | Lower-level constructor; useful for trigger publishers (independent UUID). |
416
+ | `SqsNotificationConsumer<T>` | Lower-level constructor; rarely used directly. |
417
+ | `SqsGroupNotificationPublisher<T>` / `SqsGroupNotificationConsumer<T>` | Group-cache equivalents. |
418
+ | `SqsSubscriptionOptions` | Type for `subscriptionConfig` overrides. |
419
+
420
+ ### Triggers
421
+
422
+ | Symbol | Purpose |
423
+ | --- | --- |
424
+ | `SqsInvalidationTrigger<TMessage>` | Flat-cache trigger (SQS or SNS+SQS source). |
425
+ | `SqsGroupInvalidationTrigger<TMessage>` | Group-cache trigger. |
426
+ | `SqsTriggerSource` | Discriminated source config (`'sqs-queue'` or `'sns-topic'`, with `creationConfig` or `locatorConfig`). |
427
+ | `InvalidationAction` / `GroupInvalidationAction` | Action ADTs returned by resolvers. |
428
+ | `InvalidationResolver<TMessage, TAction>` | Resolver signature. |
429
+ | `InvalidationTrigger` | `start()` / `stop()` lifecycle interface. |
430
+ | `runFlatPipeline` / `runGroupPipeline` | Reusable resolver + dispatch helpers (transport-agnostic). |
431
+ | `applyFlatAction` / `applyGroupAction` | Apply a single resolved action to a publisher. |
432
+ | `AbstractSqsTrigger` | Base class for building custom SQS-based triggers. |
433
+ | `deriveTriggerChannelName` | Helper that derives a logical channel name from a source config. |
@@ -0,0 +1,13 @@
1
+ export { SqsNotificationPublisher, type SqsNotificationPublisherConfig, type SqsNotificationPublisherParams, } from './lib/SqsNotificationPublisher.js';
2
+ export { SqsNotificationConsumer, type SqsNotificationConsumerConfig, type SqsNotificationConsumerParams, type SqsSubscriptionOptions, } from './lib/SqsNotificationConsumer.js';
3
+ export { SqsGroupNotificationPublisher, type SqsGroupNotificationPublisherConfig, type SqsGroupNotificationPublisherParams, } from './lib/SqsGroupNotificationPublisher.js';
4
+ export { SqsGroupNotificationConsumer, type SqsGroupNotificationConsumerConfig, type SqsGroupNotificationConsumerParams, } from './lib/SqsGroupNotificationConsumer.js';
5
+ export { createNotificationPair, type SqsNotificationConfig, } from './lib/SqsNotificationFactory.js';
6
+ export { createGroupNotificationPair, type SqsGroupNotificationConfig, } from './lib/SqsGroupNotificationFactory.js';
7
+ export { CLEAR_COMMAND, DELETE_COMMAND, DELETE_MANY_COMMAND, SET_COMMAND, type ClearNotificationCommand, type DeleteManyNotificationCommand, type DeleteNotificationCommand, type NotificationCommand, type SetNotificationCommand, } from './lib/notificationSchemas.js';
8
+ export { DELETE_FROM_GROUP_COMMAND, DELETE_GROUP_COMMAND, type ClearGroupNotificationCommand, type DeleteFromGroupNotificationCommand, type DeleteGroupNotificationCommand, type GroupNotificationCommand, } from './lib/groupNotificationSchemas.js';
9
+ export type { GroupInvalidationAction, InvalidationAction, InvalidationResolver, InvalidationTrigger, ResolverOutput, TriggerErrorHandler, } from './lib/triggers/types.js';
10
+ export { applyFlatAction, applyGroupAction, runFlatPipeline, runGroupPipeline, } from './lib/triggers/dispatch.js';
11
+ export { AbstractSqsTrigger, deriveTriggerChannelName, type SqsTriggerSource, } from './lib/triggers/AbstractSqsTrigger.js';
12
+ export { SqsInvalidationTrigger, type SqsInvalidationTriggerParams, type SqsTriggerSourceConfig, } from './lib/triggers/SqsInvalidationTrigger.js';
13
+ export { SqsGroupInvalidationTrigger, type SqsGroupInvalidationTriggerParams, type SqsGroupTriggerSourceConfig, } from './lib/triggers/SqsGroupInvalidationTrigger.js';
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export { SqsNotificationPublisher, } from './lib/SqsNotificationPublisher.js';
2
+ export { SqsNotificationConsumer, } from './lib/SqsNotificationConsumer.js';
3
+ export { SqsGroupNotificationPublisher, } from './lib/SqsGroupNotificationPublisher.js';
4
+ export { SqsGroupNotificationConsumer, } from './lib/SqsGroupNotificationConsumer.js';
5
+ export { createNotificationPair, } from './lib/SqsNotificationFactory.js';
6
+ export { createGroupNotificationPair, } from './lib/SqsGroupNotificationFactory.js';
7
+ export { CLEAR_COMMAND, DELETE_COMMAND, DELETE_MANY_COMMAND, SET_COMMAND, } from './lib/notificationSchemas.js';
8
+ export { DELETE_FROM_GROUP_COMMAND, DELETE_GROUP_COMMAND, } from './lib/groupNotificationSchemas.js';
9
+ export { applyFlatAction, applyGroupAction, runFlatPipeline, runGroupPipeline, } from './lib/triggers/dispatch.js';
10
+ export { AbstractSqsTrigger, deriveTriggerChannelName, } from './lib/triggers/AbstractSqsTrigger.js';
11
+ export { SqsInvalidationTrigger, } from './lib/triggers/SqsInvalidationTrigger.js';
12
+ export { SqsGroupInvalidationTrigger, } from './lib/triggers/SqsGroupInvalidationTrigger.js';
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,GAGzB,MAAM,mCAAmC,CAAA;AAC1C,OAAO,EACL,uBAAuB,GAIxB,MAAM,kCAAkC,CAAA;AACzC,OAAO,EACL,6BAA6B,GAG9B,MAAM,wCAAwC,CAAA;AAC/C,OAAO,EACL,4BAA4B,GAG7B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,sBAAsB,GAEvB,MAAM,iCAAiC,CAAA;AACxC,OAAO,EACL,2BAA2B,GAE5B,MAAM,sCAAsC,CAAA;AAC7C,OAAO,EACL,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,WAAW,GAMZ,MAAM,8BAA8B,CAAA;AACrC,OAAO,EACL,yBAAyB,EACzB,oBAAoB,GAKrB,MAAM,mCAAmC,CAAA;AAW1C,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACL,kBAAkB,EAClB,wBAAwB,GAEzB,MAAM,sCAAsC,CAAA;AAC7C,OAAO,EACL,sBAAsB,GAGvB,MAAM,0CAA0C,CAAA;AACjD,OAAO,EACL,2BAA2B,GAG5B,MAAM,+CAA+C,CAAA"}
@@ -0,0 +1,28 @@
1
+ import { type SNSSQSConsumerDependencies, type SNSSQSCreationConfig, type SNSSQSQueueLocatorType } from '@message-queue-toolkit/sns';
2
+ import type { ConsumerErrorHandler, SynchronousGroupCache } from 'layered-loader';
3
+ import { AbstractNotificationConsumer } from 'layered-loader';
4
+ import type { SqsSubscriptionOptions } from './SqsNotificationConsumer.js';
5
+ export type SqsGroupNotificationConsumerConfig = {
6
+ creationConfig: SNSSQSCreationConfig;
7
+ locatorConfig?: never;
8
+ } | {
9
+ creationConfig?: never;
10
+ locatorConfig: SNSSQSQueueLocatorType;
11
+ };
12
+ export type SqsGroupNotificationConsumerParams = {
13
+ serverUuid: string;
14
+ errorHandler?: ConsumerErrorHandler;
15
+ dependencies: SNSSQSConsumerDependencies;
16
+ subscriptionConfig?: SqsSubscriptionOptions;
17
+ } & SqsGroupNotificationConsumerConfig;
18
+ export declare class SqsGroupNotificationConsumer<LoadedValue> extends AbstractNotificationConsumer<LoadedValue, SynchronousGroupCache<LoadedValue>> {
19
+ private readonly params;
20
+ private internalConsumer?;
21
+ private subscribePromise?;
22
+ constructor(params: SqsGroupNotificationConsumerParams);
23
+ subscribe(): Promise<unknown>;
24
+ close(): Promise<void>;
25
+ get topicArn(): string | undefined;
26
+ get subscriptionArn(): string | undefined;
27
+ get queueUrl(): string | undefined;
28
+ }
@@ -0,0 +1,107 @@
1
+ import { MessageHandlerConfigBuilder } from '@message-queue-toolkit/core';
2
+ import { AbstractSnsSqsConsumer, } from '@message-queue-toolkit/sns';
3
+ import { AbstractNotificationConsumer } from 'layered-loader';
4
+ import { CLEAR_GROUP_NOTIFICATION_SCHEMA, DELETE_FROM_GROUP_NOTIFICATION_SCHEMA, DELETE_GROUP_NOTIFICATION_SCHEMA, NOTIFICATION_ID_FIELD, NOTIFICATION_TIMESTAMP_FIELD, NOTIFICATION_TYPE_FIELD, } from './groupNotificationSchemas.js';
5
+ class SnsSqsGroupInvalidationConsumer extends AbstractSnsSqsConsumer {
6
+ constructor(dependencies, options, context) {
7
+ super(dependencies, options, context);
8
+ }
9
+ get publicTopicArn() {
10
+ return this.topicArn;
11
+ }
12
+ get publicSubscriptionArn() {
13
+ return this.subscriptionArn;
14
+ }
15
+ get publicQueueUrl() {
16
+ return this.queueUrl;
17
+ }
18
+ }
19
+ export class SqsGroupNotificationConsumer extends AbstractNotificationConsumer {
20
+ params;
21
+ internalConsumer;
22
+ subscribePromise;
23
+ constructor(params) {
24
+ super(params.serverUuid, params.errorHandler);
25
+ this.params = params;
26
+ }
27
+ async subscribe() {
28
+ if (this.internalConsumer) {
29
+ return this.internalConsumer;
30
+ }
31
+ if (this.subscribePromise) {
32
+ return this.subscribePromise;
33
+ }
34
+ if (!this.targetCache) {
35
+ throw new Error('targetCache must be set via setTargetCache() before subscribe() is called');
36
+ }
37
+ const handlers = new MessageHandlerConfigBuilder()
38
+ .addConfig(DELETE_FROM_GROUP_NOTIFICATION_SCHEMA, async (message, ctx) => {
39
+ if (message.originUuid !== ctx.serverUuid) {
40
+ ctx.targetCache.deleteFromGroup(message.key, message.group);
41
+ }
42
+ return { result: 'success' };
43
+ })
44
+ .addConfig(DELETE_GROUP_NOTIFICATION_SCHEMA, async (message, ctx) => {
45
+ if (message.originUuid !== ctx.serverUuid) {
46
+ ctx.targetCache.deleteGroup(message.group);
47
+ }
48
+ return { result: 'success' };
49
+ })
50
+ .addConfig(CLEAR_GROUP_NOTIFICATION_SCHEMA, async (message, ctx) => {
51
+ if (message.originUuid !== ctx.serverUuid) {
52
+ ctx.targetCache.clear();
53
+ }
54
+ return { result: 'success' };
55
+ })
56
+ .build();
57
+ const options = {
58
+ handlers,
59
+ messageTypeResolver: { messageTypePath: NOTIFICATION_TYPE_FIELD },
60
+ messageIdField: NOTIFICATION_ID_FIELD,
61
+ messageTimestampField: NOTIFICATION_TIMESTAMP_FIELD,
62
+ subscriptionConfig: this.params.subscriptionConfig ?? { updateAttributesIfExists: false },
63
+ ...(this.params.creationConfig
64
+ ? { creationConfig: this.params.creationConfig }
65
+ : { locatorConfig: this.params.locatorConfig }),
66
+ };
67
+ const consumer = new SnsSqsGroupInvalidationConsumer(this.params.dependencies, options, { serverUuid: this.serverUuid, targetCache: this.targetCache });
68
+ // Single-flight: concurrent subscribe() calls share one init/start; the
69
+ // internal consumer is only assigned once both succeed.
70
+ this.subscribePromise = (async () => {
71
+ try {
72
+ await consumer.init();
73
+ await consumer.start();
74
+ this.internalConsumer = consumer;
75
+ return consumer;
76
+ }
77
+ catch (err) {
78
+ await consumer.close().catch(() => undefined);
79
+ throw err;
80
+ }
81
+ finally {
82
+ this.subscribePromise = undefined;
83
+ }
84
+ })();
85
+ return this.subscribePromise;
86
+ }
87
+ async close() {
88
+ if (this.subscribePromise) {
89
+ await this.subscribePromise.catch(() => undefined);
90
+ }
91
+ if (!this.internalConsumer)
92
+ return;
93
+ const consumer = this.internalConsumer;
94
+ this.internalConsumer = undefined;
95
+ await consumer.close();
96
+ }
97
+ get topicArn() {
98
+ return this.internalConsumer?.publicTopicArn;
99
+ }
100
+ get subscriptionArn() {
101
+ return this.internalConsumer?.publicSubscriptionArn;
102
+ }
103
+ get queueUrl() {
104
+ return this.internalConsumer?.publicQueueUrl;
105
+ }
106
+ }
107
+ //# sourceMappingURL=SqsGroupNotificationConsumer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SqsGroupNotificationConsumer.js","sourceRoot":"","sources":["../../lib/SqsGroupNotificationConsumer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import type { SNSDependencies, SNSSQSConsumerDependencies } from '@message-queue-toolkit/sns';
2
+ import type { ConsumerErrorHandler, PublisherErrorHandler } from 'layered-loader';
3
+ import { SqsGroupNotificationConsumer, type SqsGroupNotificationConsumerConfig } from './SqsGroupNotificationConsumer.js';
4
+ import { SqsGroupNotificationPublisher, type SqsGroupNotificationPublisherConfig } from './SqsGroupNotificationPublisher.js';
5
+ import type { SqsSubscriptionOptions } from './SqsNotificationConsumer.js';
6
+ export type SqsGroupNotificationConfig = {
7
+ channel?: string;
8
+ serverUuid?: string;
9
+ publisherErrorHandler?: PublisherErrorHandler;
10
+ consumerErrorHandler?: ConsumerErrorHandler;
11
+ publisher: {
12
+ dependencies: SNSDependencies;
13
+ } & SqsGroupNotificationPublisherConfig;
14
+ consumer: {
15
+ dependencies: SNSSQSConsumerDependencies;
16
+ subscriptionConfig?: SqsSubscriptionOptions;
17
+ } & SqsGroupNotificationConsumerConfig;
18
+ };
19
+ export declare function createGroupNotificationPair<LoadedValue>(config: SqsGroupNotificationConfig): {
20
+ publisher: SqsGroupNotificationPublisher<LoadedValue>;
21
+ consumer: SqsGroupNotificationConsumer<LoadedValue>;
22
+ };