diff --git a/CLAUDE.md b/CLAUDE.md index 2654ca5..a70ce1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 99afaa4..f3af753 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -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 - >; - 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">; }; diff --git a/src/client/index.ts b/src/client/index.ts index 1aac44e..283634f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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/) @@ -50,6 +52,35 @@ export class PushNotifications> { }); } + /** + * 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 { + 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. * diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 615e85c..1470106 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -133,6 +133,16 @@ export type ComponentApi = null, Name >; + recordPushNotificationTokenBatch: FunctionReference< + "mutation", + "internal", + { + logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; + tokens: Array<{ pushToken: string; userId: string }>; + }, + null, + Name + >; removePushNotificationToken: FunctionReference< "mutation", "internal", diff --git a/src/component/public.ts b/src/component/public.ts index b0ede46..5050e73 100644 --- a/src/component/public.ts +++ b/src/component/public.ts @@ -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, @@ -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(),