This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
// 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<DataModel extends GenericDataModel> = {
|
||||
db: GenericQueryCtx<DataModel>["db"] | GenericMutationCtx<DataModel>["db"]
|
||||
}
|
||||
|
||||
const storeOver = <DataModel extends GenericDataModel>(ctx: FunctionContext<DataModel>): AccountStore => {
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) })
|
||||
return createAccountStore({ data, system: data })
|
||||
}
|
||||
|
||||
const revocationsOver = <DataModel extends GenericDataModel>(ctx: FunctionContext<DataModel>): 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<Value> {
|
||||
try {
|
||||
const call = (store as unknown as Record<string, (...input: unknown[]) => Promise<unknown>>)[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 = <T extends string>(names: readonly T[]) =>
|
||||
v.union(
|
||||
...(names.map((name) => v.literal(name)) as [
|
||||
ReturnType<typeof v.literal<T>>,
|
||||
...ReturnType<typeof v.literal<T>>[],
|
||||
]),
|
||||
)
|
||||
|
||||
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
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user