// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // The account module on the Convex profile (joint contract §12): export my data and delete my // account, with the same ledger, ordering, idempotency and partial-failure export as on Postgres. // // `account.data.export` and `account.deletion.request` are generated actions (lib/effects.ts): their // modules' shares are calls to platform services. Their store is bound as ACCOUNT_STORE, and every // store method is one of the two internal functions below -- `createAccountStore` itself, the very // store the Postgres profile runs, over this function's ctx.db -- so each step is one Convex // transaction. A deletion is a ledger of idempotent steps the ledger records as they complete, so a // deletion interrupted between two transactions resumes from the ledger as it does on Postgres. // // storeRead / storeWrite the store's methods (internal: only this deployment's actions call them) // sweep the worker's DELETION_SWEEP_JOB, as a Convex cron (crons.ts) // providerEvent the identity provider's account events (Better Auth's deletion or ban, // from the web's revocation relay with the admin key) // revocationRead / revocationWrite // the sign-out revocation outbox's methods (internal), the Postgres // profile's own store over ctx.db // revokeSession a revoked app session (the web's sign-out, with the admin key): recorded // in the outbox and delivered at once; never blocks the sign-out // revocationSweep the worker's REVOCATION_JOB, as a one-minute Convex cron (crons.ts) import { toProblem } from "@conusai-kit/core" import { createConvexDataSource } from "@conusai-kit/data-convex" import { executorFor } from "@conusai-kit/data-convex/runtime" import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx } from "convex/server" import { ConvexError, type Value, v } from "convex/values" import { type AccountStore, createAccountStore, createRevocationStore, DELETION_SWEEP_JOB, type RevocationStore, recordProviderAccountEvent, } from "../modules/dist/account/index.js" import { internalAction, internalMutation, internalQuery } from "./_generated/server" import { ACCOUNT_STORE_READS, ACCOUNT_STORE_WRITES, type EffectActionCtx, effectContainer, REVOCATION_STORE_READS, REVOCATION_STORE_WRITES, revokeSessionInBackend, sweepRevocationsInBackend, } from "./lib/effects" import { convexEntities, registry } from "./lib/runtime" type FunctionContext = { db: GenericQueryCtx["db"] | GenericMutationCtx["db"] } const storeOver = (ctx: FunctionContext): AccountStore => { const data = createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) }) return createAccountStore({ data, system: data }) } const revocationsOver = (ctx: FunctionContext): RevocationStore => createRevocationStore(createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) })) /** One store method, its answer as plain JSON (no `undefined` crosses into Convex), or the kernel's problem. */ async function run(store: AccountStore | RevocationStore, method: string, args: unknown[]): Promise { try { const call = (store as unknown as Record Promise>)[method] if (!call) throw new Error(`no store method ${method}`) return JSON.parse(JSON.stringify((await call.apply(store, args)) ?? null)) as Value } catch (error) { throw new ConvexError(toProblem(error) as unknown as Value) } } const literals = (names: readonly T[]) => v.union( ...(names.map((name) => v.literal(name)) as [ ReturnType>, ...ReturnType>[], ]), ) export const storeRead = internalQuery({ args: { method: literals(ACCOUNT_STORE_READS), args: v.array(v.any()) }, handler: (ctx, { method, args }) => run(storeOver(ctx), method, args), }) export const storeWrite = internalMutation({ args: { method: literals(ACCOUNT_STORE_WRITES), args: v.array(v.any()) }, handler: (ctx, { method, args }) => run(storeOver(ctx), method, args), }) export const revocationRead = internalQuery({ args: { method: literals(REVOCATION_STORE_READS), args: v.array(v.any()) }, handler: (ctx, { method, args }) => run(revocationsOver(ctx), method, args), }) export const revocationWrite = internalMutation({ args: { method: literals(REVOCATION_STORE_WRITES), args: v.array(v.any()) }, handler: (ctx, { method, args }) => run(revocationsOver(ctx), method, args), }) /** * A revoked app session (joint contract §14), from the web's sign-out with the admin key: the * revocation is written to the outbox and every module that minted derived service sessions for it * is asked at once, bounded. What a platform did not confirm `revocationSweep` retries. Fails only * when nothing was recorded; the web then asks the modules itself. */ export const revokeSession = internalAction({ args: { session: v.string() }, handler: async (ctx, { session }) => { try { await revokeSessionInBackend(ctx as unknown as EffectActionCtx, session) } catch (error) { throw new ConvexError(toProblem(error) as unknown as Value) } return null }, }) /** * Delivers every due revocation, as the Postgres worker's revocation job does every minute: each row * is claimed with a versioned write and delivered once per module, with the row's backoff (30 s * doubling, at most 5 min) between attempts, until 65 minutes after the sign-out, when no widget * session it minted can still be alive and the row lapses. A run that leaves rows waiting fails with * the kernel's `account_revocations_waiting` problem, which the backend logs. */ export const revocationSweep = internalAction({ args: {}, handler: async (ctx) => { try { await sweepRevocationsInBackend(ctx as unknown as EffectActionCtx) } catch (error) { throw new ConvexError(toProblem(error) as unknown as Value) } return null }, }) /** * Resumes every deletion a module's erasure (or the provider's account end) held up, as the Postgres * worker's sweep does every five minutes: the module's own job handler, in this action's container. * A run that leaves deletions waiting fails with the kernel's `account_deletion_waiting` problem, * which the backend logs: that is the line an operator is alerted on. */ export const sweep = internalAction({ args: {}, handler: async (ctx) => { const job = registry.jobs[DELETION_SWEEP_JOB] if (!job) throw new Error("the account module declares no deletion sweep") try { await job.def.handler(null, { container: effectContainer(ctx as unknown as EffectActionCtx), correlationId: globalThis.crypto.randomUUID(), attempt: 1, }) } catch (error) { throw new ConvexError(toProblem(error) as unknown as Value) } return null }, }) /** * An account event of the app's identity provider, delivered by the web's revocation relay with the * admin key: `account-deleted` erases the account through the ledger, `user-disabled` revokes every * credential and derived service session of it. Idempotent, so a replayed event is harmless. */ export const providerEvent = internalAction({ args: { reason: v.union(v.literal("account-deleted"), v.literal("user-disabled")), userId: v.string() }, handler: async (ctx, event) => { try { await recordProviderAccountEvent(effectContainer(ctx as unknown as EffectActionCtx), event) } catch (error) { throw new ConvexError(toProblem(error) as unknown as Value) } return null }, })