Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/sqs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,18 @@ type GroupInvalidationAction =
| { kind: 'clear' }
```

Resolvers may be `async`; the trigger awaits before applying actions.
A resolver may be **synchronous or asynchronous** — both are fully supported. Return the action(s) directly for a simple field-to-key mapping, or return a `Promise` (e.g. an `async` function) when deriving the affected keys requires I/O such as a database or service lookup. The trigger always `await`s the resolver before applying actions, so the two forms behave identically:

```ts
// Synchronous: map a field straight to a key.
resolver: (msg) => ({ kind: 'delete', key: msg.userId })

// Asynchronous: look up the affected keys first.
resolver: async (msg) => {
const memberIds = await membership.listMemberIds(msg.teamId)
return { kind: 'deleteMany', keys: memberIds }
}
```

### Error handling and retries

Expand Down
9 changes: 7 additions & 2 deletions packages/sqs/lib/triggers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ export type GroupInvalidationAction =
export type ResolverOutput<TAction> = TAction | readonly TAction[] | null | undefined

/**
* Pure function turning a parsed upstream message into invalidation
* action(s). Return `undefined`/`null` to skip the message.
* Function turning a parsed upstream message into invalidation action(s).
* Return `undefined`/`null` to skip the message.
*
* May be **synchronous or asynchronous**: return the action(s) directly, or a
* `Promise` resolving to them (e.g. when you need to look up the affected keys
* from a database or another service). The trigger always `await`s the result
* before applying actions, so both forms are handled uniformly.
*/
export type InvalidationResolver<TMessage, TAction> = (
message: TMessage,
Expand Down
84 changes: 84 additions & 0 deletions packages/sqs/test/SqsInvalidationTrigger.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,90 @@ describe('SqsQueueInvalidationTrigger', () => {
}
})

it('awaits an async resolver that derives keys via an I/O lookup', async () => {
const suffix = Math.random().toString(36).slice(2, 8)
const invalidationTopic = `async-src-${suffix}`

const peer = createNotificationPair<string>({
publisher: {
dependencies: buildPublisherDeps(clients),
creationConfig: { topic: { Name: invalidationTopic } },
},
consumer: {
dependencies: buildConsumerDeps(clients),
creationConfig: {
topic: { Name: invalidationTopic },
queue: { QueueName: `async-src-peer-q-${suffix}` },
},
},
})

const loader = new Loader<string>({
inMemoryCache: IN_MEMORY_CACHE_CONFIG,
asyncCache: new StubAsyncCache('value'),
notificationConsumer: peer.consumer,
notificationPublisher: peer.publisher,
})

const upstreamQueueName = `domain-events-async-q-${suffix}`
await clients.sqsClient.send(new CreateQueueCommand({ QueueName: upstreamQueueName }))
const queueUrl = (
await clients.sqsClient.send(new GetQueueUrlCommand({ QueueName: upstreamQueueName }))
).QueueUrl!

// Stand-in for a DB/service lookup the resolver must await before it knows
// which keys an event actually affects.
const membership: Record<string, string[]> = { 'team-7': ['user-1', 'user-2'] }
const lookupMembers = (teamId: string) =>
Promise.resolve(membership[teamId] ?? [])

const trigger = new SqsQueueInvalidationTrigger({
target: loader,
dependencies: buildConsumerDeps(clients),
sources: [
{
locatorConfig: { queueUrl },
bindings: [
{
messageSchema: UPSTREAM_EVENT_SCHEMA,
resolver: async (m: UpstreamEvent) => {
if (m.eventType !== 'user.bulk-updated' || !m.userId) return null
const keys = await lookupMembers(m.userId)
return keys.length ? { kind: 'deleteMany', keys } : null
},
},
],
},
],
})

try {
await loader.init()
await trigger.start()

await loader.getAsyncOnly('user-1')
await loader.getAsyncOnly('user-2')
expect(loader.getInMemoryOnly('user-1')).toBe('value')
expect(loader.getInMemoryOnly('user-2')).toBe('value')

// userId carries the team id this event fans out to.
await clients.sqsClient.send(
new SendMessageCommand({
QueueUrl: queueUrl,
MessageBody: JSON.stringify({ eventType: 'user.bulk-updated', userId: 'team-7' }),
}),
)

await waitFor(
() =>
loader.getInMemoryOnly('user-1') === undefined &&
loader.getInMemoryOnly('user-2') === undefined,
)
} finally {
await Promise.allSettled([trigger.stop(), peer.consumer.close(), peer.publisher.close()])
}
})

it('consumes from two SQS queues simultaneously', async () => {
const suffix = Math.random().toString(36).slice(2, 8)
const invalidationTopic = `multi-sqs-${suffix}`
Expand Down
Loading