Skip to content
Draft
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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# Claude Development Notes

## Initialization

Set up the repo with `npm i && npm run build && npx convex init`

## Codegen Command

After making changes to component functions, run codegen with:

```bash
cd example/ && npm run dev -- --once
npm run build:codegen && npx convex dev --once
```

## Adding New Component Functions Pattern
Expand Down
194 changes: 1 addition & 193 deletions example/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,197 +47,5 @@ export declare const internal: FilterApi<
>;

export declare const components: {
pushNotifications: {
public: {
deleteNotificationsForUser: FunctionReference<
"mutation",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; userId: string },
null
>;
getNotification: FunctionReference<
"query",
"internal",
{ id: string; logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" },
null | {
_contentAvailable?: boolean;
_creationTime: number;
badge?: number;
body?: string;
categoryId?: string;
channelId?: string;
data?: any;
expiration?: number;
interruptionLevel?:
| "active"
| "critical"
| "passive"
| "time-sensitive";
mutableContent?: boolean;
numPreviousFailures: number;
priority?: "default" | "normal" | "high";
sound?: string | null;
state:
| "awaiting_delivery"
| "in_progress"
| "delivered"
| "needs_retry"
| "failed"
| "maybe_delivered"
| "unable_to_deliver";
subtitle?: string;
title?: string;
ttl?: number;
}
>;
getNotificationsForUser: FunctionReference<
"query",
"internal",
{
limit?: number;
logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR";
userId: string;
},
Array<{
_contentAvailable?: boolean;
_creationTime: number;
badge?: number;
body?: string;
categoryId?: string;
channelId?: string;
data?: any;
expiration?: number;
id: string;
interruptionLevel?:
| "active"
| "critical"
| "passive"
| "time-sensitive";
mutableContent?: boolean;
numPreviousFailures: number;
priority?: "default" | "normal" | "high";
sound?: string | null;
state:
| "awaiting_delivery"
| "in_progress"
| "delivered"
| "needs_retry"
| "failed"
| "maybe_delivered"
| "unable_to_deliver";
subtitle?: string;
title?: string;
ttl?: number;
}>
>;
getStatusForUser: FunctionReference<
"query",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; userId: string },
{ hasToken: boolean; paused: boolean }
>;
pauseNotificationsForUser: FunctionReference<
"mutation",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; userId: string },
null
>;
recordPushNotificationToken: FunctionReference<
"mutation",
"internal",
{
logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR";
pushToken: string;
userId: string;
},
null
>;
removePushNotificationToken: FunctionReference<
"mutation",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; userId: string },
null
>;
restart: FunctionReference<
"mutation",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" },
boolean
>;
sendPushNotification: FunctionReference<
"mutation",
"internal",
{
allowUnregisteredTokens?: boolean;
logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR";
notification: {
_contentAvailable?: boolean;
badge?: number;
body?: string;
categoryId?: string;
channelId?: string;
data?: any;
expiration?: number;
interruptionLevel?:
| "active"
| "critical"
| "passive"
| "time-sensitive";
mutableContent?: boolean;
priority?: "default" | "normal" | "high";
sound?: string | null;
subtitle?: string;
title?: string;
ttl?: number;
};
userId: string;
},
string | null
>;
sendPushNotificationBatch: FunctionReference<
"mutation",
"internal",
{
allowUnregisteredTokens?: boolean;
logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR";
notifications: Array<{
notification: {
_contentAvailable?: boolean;
badge?: number;
body?: string;
categoryId?: string;
channelId?: string;
data?: any;
expiration?: number;
interruptionLevel?:
| "active"
| "critical"
| "passive"
| "time-sensitive";
mutableContent?: boolean;
priority?: "default" | "normal" | "high";
sound?: string | null;
subtitle?: string;
title?: string;
ttl?: number;
};
userId: string;
}>;
},
Array<string | null>
>;
shutdown: FunctionReference<
"mutation",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" },
{ data?: any; message: string }
>;
unpauseNotificationsForUser: FunctionReference<
"mutation",
"internal",
{ logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; userId: string },
null
>;
};
};
pushNotifications: import("@convex-dev/expo-push-notifications/_generated/component.js").ComponentApi<"pushNotifications">;
};
31 changes: 31 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { NotificationFields } from "../component/schema.js";
import type { LogLevel } from "../logging/index.js";
import type { ComponentApi } from "../component/_generated/component.js";

const RECORD_TOKEN_BATCH_SIZE = 4000;

/**
* This component uses Expo's push notification API
* (https://docs.expo.dev/push-notifications/overview/)
Expand Down Expand Up @@ -50,6 +52,35 @@ export class PushNotifications<UserType extends string = GenericId<"users">> {
});
}

/**
* Records push notification tokens for many users at once.
*
* The list is broken into batches of {@link RECORD_TOKEN_BATCH_SIZE} users
* and each batch is sent in its own mutation. Call this from an action so
* each batch lands in its own transaction; calling from a mutation will
* still process every batch in a single transaction.
*
* @param args.tokens List of `{ userId, pushToken }` pairs to record.
* have one (matching {@link recordToken}'s behavior). If false or omitted,
* existing tokens are left alone and only new users get a token recorded.
*/
async recordTokenBatch(
ctx: RunMutationCtx,
tokens: Array<{ userId: UserType; pushToken: string }>,
): Promise<null> {
for (let i = 0; i < tokens.length; i += RECORD_TOKEN_BATCH_SIZE) {
const batch = tokens.slice(i, i + RECORD_TOKEN_BATCH_SIZE);
await ctx.runMutation(
this.component.public.recordPushNotificationTokenBatch,
{
tokens: batch,
logLevel: this.config.logLevel,
},
);
}
return null;
}

/**
* This removes the push notification token for a user if it exists.
*
Expand Down
10 changes: 10 additions & 0 deletions src/component/_generated/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
null,
Name
>;
recordPushNotificationTokenBatch: FunctionReference<
"mutation",
"internal",
{
logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR";
tokens: Array<{ pushToken: string; userId: string }>;
},
null,
Name
>;
removePushNotificationToken: FunctionReference<
"mutation",
"internal",
Expand Down
42 changes: 38 additions & 4 deletions src/component/public.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { ConvexError, v, type Infer } from "convex/values";
import { mutation, query, type MutationCtx } from "./functions.js";
import {
BASE_BATCH_DELAY,
getFutureSegment,
} from "./shared.js";
import { BASE_BATCH_DELAY, getFutureSegment } from "./shared.js";
import {
notificationFields,
notificationState,
Expand Down Expand Up @@ -40,6 +37,43 @@ export const recordPushNotificationToken = mutation({
},
});

export const recordPushNotificationTokenBatch = mutation({
args: {
tokens: v.array(
v.object({
userId: v.string(),
pushToken: v.string(),
}),
),
},
returns: v.null(),
handler: async (ctx, { tokens }) => {
await Promise.all(
tokens.map(async ({ userId, pushToken }) => {
if (pushToken === "") {
ctx.logger.debug(`Push token is empty for user ${userId}, skipping`);
return;
}
const existingToken = await ctx.db
.query("pushTokens")
.withIndex("userId", (q) => q.eq("userId", userId))
.unique();
if (existingToken !== null) {
ctx.logger.debug(
`Push token already exists for user ${userId}, updating token`,
);
await ctx.db.patch("pushTokens", existingToken._id, {
token: pushToken,
});
return;
}
await ctx.db.insert("pushTokens", { userId, token: pushToken });
}),
);
return null;
},
});

export const removePushNotificationToken = mutation({
args: {
userId: v.string(),
Expand Down
Loading