Skip to content
Open
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
221 changes: 51 additions & 170 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions packages/ai-rules/.ruler/backend/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ Send notifications through [Knock](https://docs.knock.app) using the `@clipboard
* service instead of this manual calculation.
*/
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
// Set idempotencyKey at enqueue-time so it remains stable across job retries.
idempotencyKey: {
resourceId: "event-123",
// Set idempotencyKeyParts at enqueue-time so it remains stable across job retries.
idempotencyKeyParts: {
resource: {
type: "account",
id: "4e3ffeec-1426-4e54-ad28-83246f8f4e7c",
},
},
// Set recipients at enqueue-time so they respect our notification provider's limits.
recipients: ["userId-1"],
Expand Down
9 changes: 6 additions & 3 deletions packages/notifications/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@ Send notifications through third-party providers.
* service instead of this manual calculation.
*/
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
// Set idempotencyKey at enqueue-time so it remains stable across job retries.
idempotencyKey: {
resourceId: "event-123",
// Set idempotencyKeyParts at enqueue-time so it remains stable across job retries.
idempotencyKeyParts: {
resource: {
type: "account",
id: "4e3ffeec-1426-4e54-ad28-83246f8f4e7c",
},
},
// Set recipients at enqueue-time so they respect our notification provider's limits.
recipients: ["userId-1"],
Expand Down
9 changes: 6 additions & 3 deletions packages/notifications/examples/enqueueNotificationJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ async function enqueueNotificationJob() {
* service instead of this manual calculation.
*/
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
// Set idempotencyKey at enqueue-time so it remains stable across job retries.
idempotencyKey: {
resourceId: "event-123",
// Set idempotencyKeyParts at enqueue-time so it remains stable across job retries.
idempotencyKeyParts: {
resource: {
type: "account",
id: "4e3ffeec-1426-4e54-ad28-83246f8f4e7c",
},
},
// Set recipients at enqueue-time so they respect our notification provider's limits.
recipients: ["userId-1"],
Expand Down
9 changes: 6 additions & 3 deletions packages/notifications/examples/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,12 @@
* service instead of this manual calculation.
*/
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
// Set idempotencyKey at enqueue-time so it remains stable across job retries.
idempotencyKey: {
resourceId: "event-123",
// Set idempotencyKeyParts at enqueue-time so it remains stable across job retries.
idempotencyKeyParts: {
resource: {
type: "account",
id: "4e3ffeec-1426-4e54-ad28-83246f8f4e7c",
},
},
// Set recipients at enqueue-time so they respect our notification provider's limits.
recipients: ["userId-1"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { triggerIdempotencyKeyParamsToHash } from "./triggerIdempotencyKeyParamsToHash";

describe("triggerIdempotencyKeyParamsToHash", () => {
const baseParams = {
chunk: 1,
recipients: ["user1", "user2"],
workflowKey: "test-workflow",
};

describe("resourceId extraction", () => {
it("extracts resourceId from legacy IdempotencyKey format", () => {
const input = {
...baseParams,
resourceId: "resource-123",
};

const actual = triggerIdempotencyKeyParamsToHash(input);

expect(actual).toHaveLength(64);
});

it("extracts resourceId from IdempotencyKeyParts.resource.id", () => {
const input = {
...baseParams,
resource: { type: "account", id: "resource-456" },
};

const actual = triggerIdempotencyKeyParamsToHash(input);

expect(actual).toHaveLength(64);
});

it("produces same hash for equivalent resourceId values", () => {
const legacyInput = {
...baseParams,
resourceId: "same-id",
};
const newInput = {
...baseParams,
resource: { type: "account", id: "same-id" },
};

const actualLegacy = triggerIdempotencyKeyParamsToHash(legacyInput);
const actualNew = triggerIdempotencyKeyParamsToHash(newInput);

expect(actualLegacy).toBe(actualNew);
});

it("handles eventOccurredAt without resourceId", () => {
const input = {
...baseParams,
eventOccurredAt: "2024-01-01T00:00:00.000Z",
};

const actual = triggerIdempotencyKeyParamsToHash(input);

expect(actual).toHaveLength(64);
});
});

describe("deterministic hashing", () => {
it("produces same hash for same input", () => {
const input = {
...baseParams,
resourceId: "resource-123",
};

const actual1 = triggerIdempotencyKeyParamsToHash(input);
const actual2 = triggerIdempotencyKeyParamsToHash(input);

expect(actual1).toBe(actual2);
});

it("produces same hash regardless of recipients order", () => {
const input1 = {
...baseParams,
recipients: ["user1", "user2", "user3"],
resourceId: "resource-123",
};
const input2 = {
...baseParams,
recipients: ["user3", "user1", "user2"],
resourceId: "resource-123",
};

const actual1 = triggerIdempotencyKeyParamsToHash(input1);
const actual2 = triggerIdempotencyKeyParamsToHash(input2);

expect(actual1).toBe(actual2);
});

it("produces different hash for different resourceId", () => {
const input1 = {
...baseParams,
resourceId: "resource-123",
};
const input2 = {
...baseParams,
resourceId: "resource-456",
};

const actual1 = triggerIdempotencyKeyParamsToHash(input1);
const actual2 = triggerIdempotencyKeyParamsToHash(input2);

expect(actual1).not.toBe(actual2);
});
});

describe("workplaceId", () => {
it("includes workplaceId in hash when provided", () => {
const inputWithoutWorkplace = {
...baseParams,
resourceId: "resource-123",
};
const inputWithWorkplace = {
...baseParams,
resourceId: "resource-123",
workplaceId: "workplace-1",
};

const actual1 = triggerIdempotencyKeyParamsToHash(inputWithoutWorkplace);
const actual2 = triggerIdempotencyKeyParamsToHash(inputWithWorkplace);

expect(actual1).not.toBe(actual2);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type TriggerIdempotencyKeyParams } from "../triggerIdempotencyKey";
import { createDeterministicHash } from "./createDeterministicHash";

interface HashParams extends TriggerIdempotencyKeyParams {
type HashParams = TriggerIdempotencyKeyParams & {
workplaceId?: string | undefined;
}
};

export function triggerIdempotencyKeyParamsToHash(params: HashParams): string {
return createDeterministicHash(toSorted(params));
Expand All @@ -14,7 +14,12 @@ function toSorted(params: HashParams): HashParams {
chunk: params.chunk,
eventOccurredAt: params.eventOccurredAt,
recipients: [...params.recipients].sort(),
resourceId: params.resourceId,
resourceId:
"resourceId" in params
? params.resourceId
: "resource" in params && params.resource && "id" in params.resource
? params.resource.id
: undefined,
workflowKey: params.workflowKey,
workplaceId: params.workplaceId,
};
Expand Down
91 changes: 57 additions & 34 deletions packages/notifications/src/lib/notificationJobEnqueuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,50 +22,69 @@ import {

type EnqueueParameters = Parameters<BackgroundJobsAdapter["enqueue"]>;

/**
* @deprecated Use `IdempotencyKeyParts` instead.
*/
export interface IdempotencyKey {
/**
* Prefer `resourceId` over `eventOccurredAt`; it's harder to misuse.
*
* If an event triggered your workflow and it doesn't have a unique ID, you may decide to use its
* occurrence timestamp. For example, if you have a daily CRON job, use the date it ran.
*
* Use `.toISOString()`.
*/
eventOccurredAt?: string | undefined;

/**
* If a resource triggered your workflow, include its unique ID.
*
* Note: `workflowKey`, `recipients`, and `workplaceId` (if it exists in the trigger body) are
* included in the idempotency key automatically.
*
* @example
* 1. For a "meeting starts in one hour" notification, set resourceId to the meeting ID.
* 2. For a payout notification, set resourceId to the payment ID.
*/
resourceId?: string | undefined;
}

export type IdempotencyKeyParts =
| {
/**
* Prefer `resourceId` over `eventOccurredAt`; it's harder to misuse.
*
* If an event triggered your workflow and it doesn't have a unique ID, you may decide to use its
* occurrence timestamp. For example, if you have a daily CRON job, use the date it ran.
*
* Use `.toISOString()`.
*/
eventOccurredAt: string;

resource?: undefined;
}
| {
eventOccurredAt?: undefined;

/**
* Do not include `workflowKey`, `recipients`, or `workplaceId`; they are included
* automatically.
*
* If a resource triggered your workflow, include its unique ID.
*
* @example
* 1. For a "meeting starts in one hour" notification, set resourceId to the meeting ID.
* 2. For a payout notification, set resourceId to the payment ID.
*/
resource: { type: string; id: string };
};

export interface NotificationEnqueueData {
/**
* @deprecated Use `idempotencyKeyParts` instead.
*/
idempotencyKey?: IdempotencyKey;

/**
* Do not include `workflowKey`, `recipients`, or `workplaceId`; they are included
* automatically.
*
* Idempotency keys prevent duplicate notifications. They should be deterministic and remain the
* same across retry logic.
*
* If you retry a request with the same idempotency key within 24 hours, the client returns the same
* response as the original request.
*
* Note: `workflowKey`, `recipients`, and `workplaceId` (if it exists in the trigger body) are
* included in the idempotency key automatically.
* If you retry a request with the same idempotency key within 24 hours, the client returns the
* same response as the original request.
*
* We provide this class because idempotency keys can be difficult to use correctly. If the key
* changes on each retry (e.g., Date.now() or uuid.v4()), it won't prevent duplicate notifications.
* Conversely, if you don't provide enough information, you prevent recipients from receiving
* notifications they otherwise should have. For example, if you use the trigger key and the
* recipient's ID as the idempotency key, but it's possible the recipient could receive the same
* notification multiple times within the idempotency key's validity window, the recipient will only
* receive the first notification.
* changes on each retry (e.g., Date.now() or uuid.v4()), it won't prevent duplicate
* notifications. Conversely, if you don't provide enough information, you prevent recipients from
* receiving notifications they otherwise should have. For example, if you use the trigger key and
* the recipient's ID as the idempotency key, but it's possible the recipient could receive the
* same notification multiple times within the idempotency key's validity window, the recipient
* will only receive the first notification.
*/
idempotencyKey: IdempotencyKey;
idempotencyKeyParts?: IdempotencyKeyParts;

/** @see {@link TriggerRequest.expiresAt} */
expiresAt: string;
Expand Down Expand Up @@ -182,9 +201,12 @@ export class NotificationJobEnqueuer {
* * service instead of this manual calculation.
* *\/
* expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
* // Set idempotencyKey at enqueue-time so it remains stable across job retries.
* idempotencyKey: {
* resourceId: "event-123",
* // Set idempotencyKeyParts at enqueue-time so it remains stable across job retries.
* idempotencyKeyParts: {
* resource: {
* type: "account",
* id: "4e3ffeec-1426-4e54-ad28-83246f8f4e7c",
* },
* },
* // Set recipients at enqueue-time so they respect our notification provider's limits.
* recipients: ["userId-1"],
Expand Down Expand Up @@ -212,6 +234,7 @@ export class NotificationJobEnqueuer {
chunkRecipients({ recipients: data.recipients }).map(async ({ number, recipients }) => {
const idempotencyKeyParams: TriggerIdempotencyKeyParams = {
...data.idempotencyKey,
...data.idempotencyKeyParts,
chunk: number,
recipients,
workflowKey: data.workflowKey,
Expand Down
5 changes: 3 additions & 2 deletions packages/notifications/src/lib/triggerIdempotencyKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Tagged } from "type-fest";

import {
type IdempotencyKey,
type IdempotencyKeyParts,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type NotificationJobEnqueuer,
} from "./notificationJobEnqueuer";
Expand All @@ -18,7 +19,7 @@ import {
*/
export type TriggerIdempotencyKey = Tagged<string, "TriggerIdempotencyKey">;

export interface TriggerIdempotencyKeyParams extends IdempotencyKey {
export type TriggerIdempotencyKeyParams = (IdempotencyKey | IdempotencyKeyParts) & {
/**
* The recipient chunk number.
*/
Expand All @@ -33,7 +34,7 @@ export interface TriggerIdempotencyKeyParams extends IdempotencyKey {
* The workflow key.
*/
workflowKey: string;
}
};

/**
* Type guard to check if a value is a valid TriggerIdempotencyKeyParams object.
Expand Down