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
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Audit delivery reads the root deployment credentials through an internal action.
|
||||
import { v } from "convex/values"
|
||||
import { internalAction } from "./_generated/server"
|
||||
|
||||
export const get = internalAction({
|
||||
args: {},
|
||||
returns: v.object({ apiUrl: v.union(v.string(), v.null()), apiToken: v.union(v.string(), v.null()) }),
|
||||
handler: async () => ({
|
||||
apiUrl: process.env.CONUSAI_API_URL ?? null,
|
||||
apiToken: process.env.CONUSAI_API_TOKEN ?? null,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The app calls Convex only from its same-origin Next BFF. That server holds the linked Convex
|
||||
// admin key and attaches a synthetic identity with issuer CONVEX_BFF_ISSUER. No external OIDC
|
||||
// JWT issuer is configured here: OIDC credentials and cookies remain server-side in kit-auth.
|
||||
import type { AuthConfig } from "convex/server"
|
||||
|
||||
export default { providers: [] } satisfies AuthConfig
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The jobs dispatcher (spec §5.4: jobs are the Convex scheduler; the workpool component is this
|
||||
// same dispatch under parallelism). A module's enqueue is a row plus a scheduled run of this
|
||||
// mutation, inside the enqueueing transaction; this function re-reads the row, validates the
|
||||
// payload against the module's declared schema, runs the handler with a JobContext, and retries
|
||||
// or dead-letters.
|
||||
|
||||
import { Container, type JobContext } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, JOB_QUEUE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import {
|
||||
createConvexDataSource,
|
||||
createConvexJobQueue,
|
||||
dispatchConvexJob,
|
||||
reclaimConvexActionJobs,
|
||||
settleConvexActionJob,
|
||||
} from "@conusai-kit/data-convex"
|
||||
import { executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { v } from "convex/values"
|
||||
import { internalMutation } from "./_generated/server"
|
||||
import { convexEntities, registry } from "./lib/runtime"
|
||||
|
||||
export const run = internalMutation({
|
||||
args: { jobId: v.string(), inline: v.optional(v.any()) },
|
||||
handler: async (ctx, args) => {
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) })
|
||||
const dispatch = makeFunctionReference<"mutation">("conusaiJobs:run")
|
||||
await dispatchConvexJob(
|
||||
ctx.db,
|
||||
{ jobId: args.jobId, ...(args.inline ? { inline: args.inline as never } : {}) },
|
||||
{
|
||||
jobs: Object.fromEntries(Object.entries(registry.jobs).map(([name, { def }]) => [name, def])),
|
||||
schedule: async ({ delayMs, jobId, inline }) => {
|
||||
await ctx.scheduler.runAfter(delayMs, dispatch, { jobId, ...(inline ? { inline } : {}) })
|
||||
},
|
||||
run: async (job, payload, call) => {
|
||||
if (job === "documents.ocr") {
|
||||
if (call.scope.level !== "project") throw new Error("documents.ocr requires a project job scope")
|
||||
await ctx.scheduler.runAfter(0, makeFunctionReference<"action">("documentsActions:process"), {
|
||||
jobId: call.jobId,
|
||||
attempt: call.attempt,
|
||||
orgId: call.scope.orgId,
|
||||
workspaceId: call.scope.workspaceId,
|
||||
projectId: call.scope.projectId,
|
||||
payload,
|
||||
})
|
||||
return { deferred: true }
|
||||
}
|
||||
const def = registry.jobs[job]?.def
|
||||
if (!def) throw new Error(`no job named ${job} is registered`)
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
container.value(
|
||||
JOB_QUEUE,
|
||||
createConvexJobQueue({
|
||||
db: ctx.db,
|
||||
schedule: ({ delayMs, jobId, inline }) =>
|
||||
ctx.scheduler
|
||||
.runAfter(delayMs, dispatch, { jobId, ...(inline ? { inline } : {}) })
|
||||
.then(() => undefined),
|
||||
}),
|
||||
)
|
||||
const context: JobContext = { container, correlationId: call.correlationId, attempt: call.attempt }
|
||||
await def.handler(payload, context)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
/** An OCR action records its acknowledgement or retry back through the durable queue row. */
|
||||
export const settleAction = internalMutation({
|
||||
args: {
|
||||
jobId: v.string(),
|
||||
attempt: v.number(),
|
||||
error: v.optional(v.string()),
|
||||
retryAfterSeconds: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const dispatch = makeFunctionReference<"mutation">("conusaiJobs:run")
|
||||
await settleConvexActionJob(ctx.db as never, args, {
|
||||
schedule: async ({ delayMs, jobId }) => {
|
||||
await ctx.scheduler.runAfter(delayMs, dispatch, { jobId })
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/** Requeue an action job whose lease expired after the Convex action process died. */
|
||||
export const reclaimActionJobs = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const dispatch = makeFunctionReference<"mutation">("conusaiJobs:run")
|
||||
await reclaimConvexActionJobs(ctx.db as never, {
|
||||
schedule: async ({ delayMs, jobId }) => {
|
||||
await ctx.scheduler.runAfter(delayMs, dispatch, { jobId })
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex app definition: registers the ConusAI audit component, so audit events written from
|
||||
// Convex functions (the outbox relay's subscribers, and any procedure's own audit) reach the
|
||||
// platform's tenant audit log through the scheduler (spec §5.4, @conusai-sdk/convex-audit).
|
||||
|
||||
import audit from "@conusai-sdk/convex-audit/convex.config"
|
||||
import { defineApp } from "convex/server"
|
||||
|
||||
const app = defineApp()
|
||||
app.use(audit)
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"functions": "convex/"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex cron registrations of this app: the outbox relay runs every minute (spec §5.4's
|
||||
// "outbox events are scheduled internal mutations", drained on a beat). Backups run in the
|
||||
// profile's sidecar because `convex export` is an admin CLI operation, not a Convex function.
|
||||
|
||||
import { cronJobs } from "convex/server"
|
||||
import { internal } from "./_generated/api"
|
||||
|
||||
const crons = cronJobs()
|
||||
crons.interval("outbox relay", { minutes: 1 }, internal.relay.relayPass, {})
|
||||
crons.interval("file upload and delete recovery", { minutes: 5 }, internal.files.recoverRows, {})
|
||||
crons.interval("action job lease recovery", { minutes: 5 }, internal.conusaiJobs.reclaimActionJobs, {})
|
||||
// The account module's deletion sweep (the Postgres worker's DELETION_SWEEP_JOB, every five minutes).
|
||||
crons.interval("account deletion sweep", { minutes: 5 }, internal.account.sweep, {})
|
||||
// The sign-out revocation outbox (the Postgres worker's REVOCATION_JOB, every minute): each row carries
|
||||
// its own backoff, so a run asks only the modules whose retry is due.
|
||||
crons.interval("account session revocations", { minutes: 1 }, internal.account.revocationSweep, {})
|
||||
|
||||
export default crons
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Convex transactions for the documents OCR action. External OCR/model/Blob work runs in the Node
|
||||
// action; these functions perform one scoped row transition each, and proposal creation itself is
|
||||
// still one mutation containing the proposal plus its outbox record.
|
||||
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { createConvexDataSource } from "@conusai-kit/data-convex"
|
||||
import { executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import { files } from "@conusai-kit/files"
|
||||
import { createProposal, DEFAULT_PROPOSALS_POLICY, type ProposalView, proposals } from "@conusai-kit/proposals"
|
||||
import { createTenancyHooks } from "@conusai-kit/tenancy"
|
||||
import { ConvexError, type Value, v } from "convex/values"
|
||||
import { documents } from "../modules/dist/documents/contract/entities.js"
|
||||
import { internalMutation, internalQuery } from "./_generated/server"
|
||||
import { convexEntities, registry } from "./lib/runtime"
|
||||
|
||||
const scopeArgs = {
|
||||
orgId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
projectId: v.string(),
|
||||
}
|
||||
const withFile = { ...scopeArgs, fileId: v.string() }
|
||||
|
||||
type ProjectScope = TenantScope & { workspaceId: string; projectId: string }
|
||||
|
||||
const systemScope = (ids: { orgId: string; workspaceId: string; projectId: string }): ProjectScope => ({
|
||||
level: "project",
|
||||
actor: systemActor("documents.ocr"),
|
||||
...ids,
|
||||
})
|
||||
|
||||
const dataFor = (ctx: { db: unknown }) =>
|
||||
createConvexDataSource({
|
||||
entities: convexEntities,
|
||||
executor: executorFor(ctx as unknown as Parameters<typeof executorFor>[0]),
|
||||
})
|
||||
|
||||
const problem = (message: string): never => {
|
||||
throw new ConvexError({ code: "internal", message } as unknown as Value)
|
||||
}
|
||||
|
||||
export const fileInfo = internalQuery({
|
||||
args: withFile,
|
||||
handler: async (ctx, { fileId, ...ids }) => {
|
||||
const row = await dataFor(ctx).forScope(systemScope(ids)).of(files).get(fileId)
|
||||
if (row?.status !== "stored" || row.size === null || row.sha256 === null || row.storedAt === null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
fileId: row.id,
|
||||
filename: row.filename,
|
||||
contentType: row.contentType,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
uploadedBy: row.uploadedBy,
|
||||
createdAt: row.createdAt,
|
||||
storedAt: row.storedAt,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ensureProcessing = internalMutation({
|
||||
args: { ...withFile, storageKey: v.string(), sha256: v.string() },
|
||||
handler: async (ctx, { fileId, storageKey, sha256, ...ids }) => {
|
||||
const scope = systemScope(ids)
|
||||
const data = dataFor(ctx)
|
||||
const file = await data.forScope(scope).of(files).get(fileId)
|
||||
if (
|
||||
file?.status !== "stored" ||
|
||||
file.storageKey !== storageKey ||
|
||||
file.sha256 !== sha256 ||
|
||||
file.size === null ||
|
||||
file.storedAt === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const repo = data.forScope(scope).of(documents)
|
||||
const existing = (await repo.list({ where: [{ field: "fileId", op: "eq", value: fileId }], limit: 1 })).items[0]
|
||||
if (existing) return existing.status === "processing" ? existing : null
|
||||
try {
|
||||
return await repo.insert({
|
||||
fileId,
|
||||
filename: file.filename,
|
||||
status: "processing",
|
||||
ocrJobId: null,
|
||||
pageCount: null,
|
||||
proposedCount: null,
|
||||
failureCode: null,
|
||||
skippedPages: null,
|
||||
finishedAt: null,
|
||||
} as never)
|
||||
} catch (error) {
|
||||
const raced = (await repo.list({ where: [{ field: "fileId", op: "eq", value: fileId }], limit: 1 })).items[0]
|
||||
if (raced) return raced.status === "processing" ? raced : null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const setOcrJob = internalMutation({
|
||||
args: { ...scopeArgs, documentId: v.string(), expectedVersion: v.number(), jobId: v.number() },
|
||||
handler: async (ctx, { documentId, expectedVersion, jobId, ...ids }) => {
|
||||
const repo = dataFor(ctx).forScope(systemScope(ids)).of(documents)
|
||||
const current = await repo.get(documentId)
|
||||
if (current?.status !== "processing") return problem("document processing row is no longer active")
|
||||
if (current.ocrJobId !== null) return current
|
||||
return repo.update(documentId, { ocrJobId: jobId } as never, expectedVersion)
|
||||
},
|
||||
})
|
||||
|
||||
export const markFailed = internalMutation({
|
||||
args: {
|
||||
...scopeArgs,
|
||||
documentId: v.string(),
|
||||
expectedVersion: v.number(),
|
||||
code: v.union(
|
||||
v.literal("file_missing"),
|
||||
v.literal("file_changed"),
|
||||
v.literal("ocr_failed"),
|
||||
v.literal("extraction_refused"),
|
||||
v.literal("extraction_truncated"),
|
||||
v.literal("extraction_invalid"),
|
||||
),
|
||||
finishedAt: v.string(),
|
||||
},
|
||||
handler: async (ctx, { documentId, expectedVersion, code, finishedAt, ...ids }) => {
|
||||
const repo = dataFor(ctx).forScope(systemScope(ids)).of(documents)
|
||||
const current = await repo.get(documentId)
|
||||
if (current?.status !== "processing") return null
|
||||
return repo.update(documentId, { status: "failed", failureCode: code, finishedAt } as never, expectedVersion)
|
||||
},
|
||||
})
|
||||
|
||||
export const lineProposals = internalQuery({
|
||||
args: { ...scopeArgs, documentId: v.string() },
|
||||
handler: async (ctx, { documentId, ...ids }) => {
|
||||
const repo = dataFor(ctx).forScope(systemScope(ids)).of(proposals)
|
||||
const proposalsForDocument: ProposalView[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await repo.list({
|
||||
where: [
|
||||
{ field: "targetKey", op: "eq", value: "documents.line" },
|
||||
{ field: "targetRecordId", op: "eq", value: documentId },
|
||||
],
|
||||
limit: 200,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
proposalsForDocument.push(...(page.items as unknown as ProposalView[]))
|
||||
cursor = page.hasMore ? page.cursor : undefined
|
||||
} while (cursor !== undefined)
|
||||
return proposalsForDocument.map((proposal) => ({ lineKey: (proposal.change as { lineKey?: string }).lineKey }))
|
||||
},
|
||||
})
|
||||
|
||||
export const addLineProposal = internalMutation({
|
||||
args: {
|
||||
...scopeArgs,
|
||||
documentId: v.string(),
|
||||
input: v.any(),
|
||||
idempotencyKey: v.string(),
|
||||
},
|
||||
handler: async (ctx, { documentId, input, idempotencyKey, ...ids }) => {
|
||||
const scope = systemScope(ids)
|
||||
const data = dataFor(ctx)
|
||||
const key = (input.change as { lineKey?: string }).lineKey
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await data
|
||||
.forScope(scope)
|
||||
.of(proposals)
|
||||
.list({
|
||||
where: [
|
||||
{ field: "targetKey", op: "eq", value: "documents.line" },
|
||||
{ field: "targetRecordId", op: "eq", value: documentId },
|
||||
],
|
||||
limit: 200,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
if (page.items.some((proposal) => (proposal.change as { lineKey?: string }).lineKey === key)) return null
|
||||
cursor = page.hasMore ? page.cursor : undefined
|
||||
} while (cursor !== undefined)
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
return createProposal(
|
||||
{ data, registry, tenancy, policy: DEFAULT_PROPOSALS_POLICY },
|
||||
scope,
|
||||
{
|
||||
targetKey: "documents.line",
|
||||
targetRecordId: documentId,
|
||||
...(input as Record<string, unknown>),
|
||||
} as never,
|
||||
idempotencyKey,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const finish = internalMutation({
|
||||
args: {
|
||||
...scopeArgs,
|
||||
documentId: v.string(),
|
||||
expectedVersion: v.number(),
|
||||
pageCount: v.number(),
|
||||
proposedCount: v.number(),
|
||||
skippedPages: v.array(v.any()),
|
||||
finishedAt: v.string(),
|
||||
},
|
||||
handler: async (ctx, { documentId, expectedVersion, ...result }) => {
|
||||
const { orgId, workspaceId, projectId, ...fields } = result
|
||||
const repo = dataFor(ctx).forScope(systemScope({ orgId, workspaceId, projectId })).of(documents)
|
||||
const current = await repo.get(documentId)
|
||||
if (current?.status !== "processing") return null
|
||||
return repo.update(documentId, { ...fields, status: "extracted", failureCode: null } as never, expectedVersion)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Node action for the documents pipeline. OCR, Blob and model calls happen here; every repository
|
||||
// change below is a single internal mutation/query and proposal creation remains atomic with its
|
||||
// outbox event.
|
||||
|
||||
"use node"
|
||||
|
||||
import { env } from "node:process"
|
||||
import { AppError, notFound } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { DEFAULT_FILES_POLICY, type ProcessorJobPayload, platformObjectStore } from "@conusai-kit/files"
|
||||
import { createDocumentReader, createModelGateway, createObjectStore } from "@conusai-kit/platform"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { v } from "convex/values"
|
||||
import { excludedPageClassNamesOf } from "../config/dist/documents.js"
|
||||
import { pageClassesOf } from "../modules/dist/documents/contract/constants.js"
|
||||
import type { DocumentScope } from "../modules/dist/documents/core/lines.js"
|
||||
import {
|
||||
type DocumentProcessRecords,
|
||||
type ProcessDocumentRow,
|
||||
processDocumentWithRecords,
|
||||
} from "../modules/dist/documents/core/process.js"
|
||||
import { modelLineExtractor, sdkDocumentOcr } from "../modules/dist/documents/index.js"
|
||||
import { internalAction } from "./_generated/server"
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = env[name]?.trim()
|
||||
if (!value) throw new Error(`Convex deployment is missing ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const serviceEnv = () => {
|
||||
const project = Number(required("CONUSAI_PROJECT_ID"))
|
||||
if (!Number.isSafeInteger(project) || project < 1)
|
||||
throw new Error("Convex deployment has an invalid CONUSAI_PROJECT_ID")
|
||||
return {
|
||||
CONUSAI_API_URL: required("CONUSAI_API_URL"),
|
||||
CONUSAI_API_TOKEN: required("CONUSAI_API_TOKEN"),
|
||||
CONUSAI_PROJECT_ID: String(project),
|
||||
}
|
||||
}
|
||||
|
||||
const platformBlobStore = (deployment: ReturnType<typeof serviceEnv>) =>
|
||||
platformObjectStore({ blob: createObjectStore(deployment) })
|
||||
|
||||
const queryRef = (name: string) => makeFunctionReference<"query">(name)
|
||||
const mutationRef = (name: string) => makeFunctionReference<"mutation">(name)
|
||||
|
||||
const documentScope = (ids: { orgId: string; workspaceId: string; projectId: string }): DocumentScope => ({
|
||||
level: "project",
|
||||
actor: systemActor("documents.ocr"),
|
||||
...ids,
|
||||
})
|
||||
|
||||
export const process = internalAction({
|
||||
args: {
|
||||
jobId: v.string(),
|
||||
attempt: v.number(),
|
||||
orgId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
projectId: v.string(),
|
||||
payload: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ids = { orgId: args.orgId, workspaceId: args.workspaceId, projectId: args.projectId }
|
||||
const scope = documentScope(ids)
|
||||
const deployment = serviceEnv()
|
||||
const store = platformBlobStore(deployment)
|
||||
const reader = sdkDocumentOcr(createDocumentReader(deployment))
|
||||
const model = env.AI_MODEL?.trim() || "anthropic/claude-sonnet-5"
|
||||
const excludedPageClassNames = excludedPageClassNamesOf()
|
||||
const extractor = modelLineExtractor({
|
||||
gateway: createModelGateway(deployment, { model }),
|
||||
model,
|
||||
...(excludedPageClassNames === undefined ? {} : { excludedPageClasses: pageClassesOf(excludedPageClassNames) }),
|
||||
})
|
||||
const payload = args.payload as ProcessorJobPayload
|
||||
const records: DocumentProcessRecords = {
|
||||
async getFile(_scope, fileId) {
|
||||
const file = await ctx.runQuery(queryRef("documents:fileInfo"), { ...ids, fileId })
|
||||
if (!file) throw notFound("no such file")
|
||||
return file as never
|
||||
},
|
||||
ensureProcessing: async (_scope, _file, job) =>
|
||||
(await ctx.runMutation(mutationRef("documents:ensureProcessing"), {
|
||||
...ids,
|
||||
fileId: job.fileId,
|
||||
storageKey: job.storageKey,
|
||||
sha256: job.sha256,
|
||||
})) as ProcessDocumentRow | undefined,
|
||||
setOcrJob: async (_scope, document, jobId) =>
|
||||
(await ctx.runMutation(mutationRef("documents:setOcrJob"), {
|
||||
...ids,
|
||||
documentId: document.id,
|
||||
expectedVersion: document.version,
|
||||
jobId,
|
||||
})) as ProcessDocumentRow,
|
||||
markFailed: async (_scope, document, code, finishedAt) => {
|
||||
await ctx.runMutation(mutationRef("documents:markFailed"), {
|
||||
...ids,
|
||||
documentId: document.id,
|
||||
expectedVersion: document.version,
|
||||
code,
|
||||
finishedAt,
|
||||
})
|
||||
},
|
||||
proposals: async (_scope, documentId) => {
|
||||
const lines = await ctx.runQuery(queryRef("documents:lineProposals"), { ...ids, documentId })
|
||||
return lines.map(({ lineKey }: { lineKey?: string }) => ({ change: { lineKey } })) as never
|
||||
},
|
||||
createProposal: async (_scope, input, idempotencyKey) => {
|
||||
await ctx.runMutation(mutationRef("documents:addLineProposal"), {
|
||||
...ids,
|
||||
documentId: input.targetRecordId,
|
||||
input,
|
||||
idempotencyKey,
|
||||
})
|
||||
},
|
||||
finish: async (_scope, document, result) => {
|
||||
await ctx.runMutation(mutationRef("documents:finish"), {
|
||||
...ids,
|
||||
documentId: document.id,
|
||||
expectedVersion: document.version,
|
||||
...result,
|
||||
})
|
||||
},
|
||||
}
|
||||
try {
|
||||
await processDocumentWithRecords(
|
||||
{
|
||||
store,
|
||||
ocr: reader,
|
||||
extractor,
|
||||
maxBytes: DEFAULT_FILES_POLICY.maxBytes,
|
||||
},
|
||||
scope,
|
||||
payload,
|
||||
records,
|
||||
)
|
||||
await ctx.runMutation(mutationRef("conusaiJobs:settleAction"), {
|
||||
jobId: args.jobId,
|
||||
attempt: args.attempt,
|
||||
})
|
||||
} catch (error) {
|
||||
const retryAfterSeconds =
|
||||
error instanceof AppError && error.problem.code === "rate_limited" ? error.problem.retryAfter : undefined
|
||||
const errorCode = error instanceof AppError ? error.problem.code : error instanceof Error ? error.name : "unknown"
|
||||
await ctx.runMutation(mutationRef("conusaiJobs:settleAction"), {
|
||||
jobId: args.jobId,
|
||||
attempt: args.attempt,
|
||||
error: `documents.ocr failed (${errorCode})`,
|
||||
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tenant wall of the effect actions (lib/effects.ts). The kernel in an action resolves the
|
||||
// tenant scope and checks the permission here, inside a query, with the same tenancy hooks over
|
||||
// ctx.db that every other procedure uses. Both are internal: only this deployment's own actions
|
||||
// call them, and they derive the actor and the organization pin from the caller's verified
|
||||
// identity, never from their arguments.
|
||||
|
||||
import { type Actor, type AnyScope, notFound, toProblem, unauthenticated } from "@conusai-kit/core"
|
||||
import { ConvexError, type Value, v } from "convex/values"
|
||||
import { internalQuery } from "./_generated/server"
|
||||
import { callContextOf, tenancyOver } from "./lib/runtime"
|
||||
|
||||
const level = v.union(v.literal("org"), v.literal("workspace"), v.literal("project"))
|
||||
|
||||
async function verifiedActor(
|
||||
auth: Parameters<typeof callContextOf>[0],
|
||||
): Promise<{ actor: Actor; pinnedOrgId?: string }> {
|
||||
const { actor, pinnedOrgId } = await callContextOf(auth)
|
||||
if (!actor) throw unauthenticated()
|
||||
return { actor, ...(pinnedOrgId === undefined ? {} : { pinnedOrgId }) }
|
||||
}
|
||||
|
||||
const problem = (error: unknown): never => {
|
||||
throw new ConvexError(toProblem(error) as unknown as Value)
|
||||
}
|
||||
|
||||
/** The scope ids of a tenant-level call, or the kernel's problem (a stranger's notFound). */
|
||||
export const resolve = internalQuery({
|
||||
args: { level, input: v.any() },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
const { actor, pinnedOrgId } = await verifiedActor(ctx.auth)
|
||||
const scope = (await tenancyOver(ctx.db).resolveScope(args.level, args.input, actor, pinnedOrgId)) as AnyScope & {
|
||||
orgId: string
|
||||
workspaceId?: string
|
||||
projectId?: string
|
||||
}
|
||||
if (pinnedOrgId !== undefined && scope.orgId !== pinnedOrgId) throw notFound("no scope in this organization")
|
||||
return {
|
||||
orgId: scope.orgId,
|
||||
...(scope.workspaceId === undefined ? {} : { workspaceId: scope.workspaceId }),
|
||||
...(scope.projectId === undefined ? {} : { projectId: scope.projectId }),
|
||||
}
|
||||
} catch (error) {
|
||||
return problem(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Whether the verified actor holds `permission` in the scope, for this procedure. */
|
||||
export const authorize = internalQuery({
|
||||
args: {
|
||||
level,
|
||||
scope: v.object({ orgId: v.string(), workspaceId: v.optional(v.string()), projectId: v.optional(v.string()) }),
|
||||
permission: v.string(),
|
||||
procedure: v.object({
|
||||
name: v.string(),
|
||||
kind: v.union(v.literal("read"), v.literal("write"), v.literal("effect")),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
const { actor, pinnedOrgId } = await verifiedActor(ctx.auth)
|
||||
if (pinnedOrgId !== undefined && args.scope.orgId !== pinnedOrgId) return false
|
||||
const scope = { level: args.level, actor, ...args.scope } as AnyScope
|
||||
return await tenancyOver(ctx.db).authorize(scope, args.permission, args.procedure)
|
||||
} catch (error) {
|
||||
return problem(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Database transitions for the Convex file action workflows. Each handler is one real mutation;
|
||||
// actions in filesActions.ts perform Blob effects between these durable state transitions.
|
||||
|
||||
import { type Actor, AppError, notFound, toProblem, unauthenticated, validationFailed } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { createConvexDataSource } from "@conusai-kit/data-convex"
|
||||
import { executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import {
|
||||
beginDelete,
|
||||
beginUploadIntent,
|
||||
DEFAULT_FILES_POLICY,
|
||||
type FileScope,
|
||||
fileBeginUpload,
|
||||
fileCompleteUpload,
|
||||
fileDelete,
|
||||
fileGet,
|
||||
filePutPart,
|
||||
files,
|
||||
finalize,
|
||||
finishDeleteRecord,
|
||||
markAbandoned,
|
||||
recordUploadSession,
|
||||
} from "@conusai-kit/files"
|
||||
import { createTenancyHooks } from "@conusai-kit/tenancy"
|
||||
import type { UserIdentity } from "convex/server"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { ConvexError, type Value, v } from "convex/values"
|
||||
import { internalMutation, query } from "./_generated/server"
|
||||
import { CONVEX_BFF_ISSUER, convexEntities, registry, unavailableObjectStore } from "./lib/runtime"
|
||||
|
||||
const trustedIdentity = (identity: UserIdentity): Actor => {
|
||||
if (identity.issuer !== CONVEX_BFF_ISSUER || !identity.subject) {
|
||||
throwProblem(unauthenticated("file actions accept only an authenticated app BFF identity"))
|
||||
}
|
||||
return { userId: identity.subject }
|
||||
}
|
||||
|
||||
function throwProblem(error: unknown): never {
|
||||
if (error instanceof AppError) throw new ConvexError(toProblem(error) as unknown as Value)
|
||||
throw error
|
||||
}
|
||||
|
||||
const scopeFor = async (
|
||||
data: ReturnType<typeof createConvexDataSource>,
|
||||
identity: UserIdentity,
|
||||
input: unknown,
|
||||
permission: string,
|
||||
procedure: string,
|
||||
kind: "read" | "write" | "effect" = "effect",
|
||||
): Promise<FileScope> => {
|
||||
const actor = trustedIdentity(identity)
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
try {
|
||||
const scope = await tenancy.resolveScope(
|
||||
"project",
|
||||
input,
|
||||
actor,
|
||||
(identity as UserIdentity & { conusaiOrgId?: string }).conusaiOrgId,
|
||||
)
|
||||
if (!(await tenancy.authorize(scope, permission, { name: procedure, kind }))) {
|
||||
throw notFound(`no ${procedure} in this scope`)
|
||||
}
|
||||
return scope as FileScope
|
||||
} catch (error) {
|
||||
throwProblem(error)
|
||||
}
|
||||
}
|
||||
|
||||
const identityValidator = v.object({
|
||||
issuer: v.string(),
|
||||
subject: v.string(),
|
||||
tokenIdentifier: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
name: v.optional(v.string()),
|
||||
conusaiOrgId: v.optional(v.string()),
|
||||
})
|
||||
|
||||
const dataFor = (ctx: { db: unknown }) =>
|
||||
createConvexDataSource({
|
||||
entities: convexEntities,
|
||||
executor: executorFor(ctx as unknown as Parameters<typeof executorFor>[0]),
|
||||
})
|
||||
const actionRef = (name: string) => makeFunctionReference<"action">(name)
|
||||
|
||||
export const beginIntent = internalMutation({
|
||||
args: { identity: identityValidator, input: v.any() },
|
||||
handler: async (ctx, { identity, input }) => {
|
||||
const data = dataFor(ctx)
|
||||
const parsed = fileBeginUpload.input.safeParse(input)
|
||||
if (!parsed.success) throwProblem(validationFailed([{ path: "input", message: "invalid file upload input" }]))
|
||||
const scope = await scopeFor(data, identity, parsed.data, "files.file.write", "files.file.beginUpload")
|
||||
const intent = await beginUploadIntent({ data, policy: DEFAULT_FILES_POLICY }, scope, {
|
||||
filename: parsed.data.filename,
|
||||
contentType: parsed.data.contentType,
|
||||
size: parsed.data.size,
|
||||
uploadedBy: scope.actor.userId,
|
||||
}).catch(throwProblem)
|
||||
return { intent, scope: { orgId: scope.orgId, workspaceId: scope.workspaceId, projectId: scope.projectId } }
|
||||
},
|
||||
})
|
||||
|
||||
export const saveUploadSession = internalMutation({
|
||||
args: { identity: identityValidator, input: v.any(), intent: v.any(), plan: v.any() },
|
||||
handler: async (ctx, { identity, input, intent, plan }) => {
|
||||
const data = dataFor(ctx)
|
||||
const scope = await scopeFor(data, identity, input, "files.file.write", "files.file.beginUpload")
|
||||
const row = await data.forScope(scope).of(files).get(intent.fileId)
|
||||
if (!row || row.uploadedBy !== scope.actor.userId || row.storageKey !== intent.storageKey) {
|
||||
throwProblem(notFound("no such upload"))
|
||||
}
|
||||
return recordUploadSession(data, scope, intent, plan).catch(throwProblem)
|
||||
},
|
||||
})
|
||||
|
||||
export const completeInfo = internalMutation({
|
||||
args: { identity: identityValidator, input: v.any() },
|
||||
handler: async (ctx, { identity, input }) => {
|
||||
const data = dataFor(ctx)
|
||||
const parsed = fileCompleteUpload.input.safeParse(input)
|
||||
if (!parsed.success) throwProblem(validationFailed([{ path: "input", message: "invalid file completion input" }]))
|
||||
const scope = await scopeFor(data, identity, parsed.data, "files.file.write", "files.file.completeUpload")
|
||||
const row = await data.forScope(scope).of(files).get(parsed.data.fileId)
|
||||
if (!row || row.uploadedBy !== scope.actor.userId) throwProblem(notFound("no such upload"))
|
||||
if (row.status === "stored") {
|
||||
return {
|
||||
alreadyStored: true,
|
||||
result: {
|
||||
fileId: row.id,
|
||||
filename: row.filename,
|
||||
contentType: row.contentType,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
uploadedBy: row.uploadedBy,
|
||||
createdAt: row.createdAt,
|
||||
storedAt: row.storedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (row.status !== "pending" || !row.uploadId) throwProblem(notFound("no such upload"))
|
||||
return {
|
||||
alreadyStored: false,
|
||||
scope: { orgId: scope.orgId, workspaceId: scope.workspaceId, projectId: scope.projectId },
|
||||
fileId: row.id,
|
||||
storageKey: row.storageKey,
|
||||
uploadId: row.uploadId,
|
||||
declaredSize: row.declaredSize,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Metadata for the web BFF's streamed PUT. File bytes never become Convex action arguments. */
|
||||
export const partInfo = query({
|
||||
args: { input: v.any() },
|
||||
handler: async (ctx, { input }) => {
|
||||
const identity = await ctx.auth.getUserIdentity()
|
||||
if (!identity) throwProblem(unauthenticated())
|
||||
trustedIdentity(identity)
|
||||
const parsed = filePutPart.input.omit({ body: true }).safeParse(input)
|
||||
if (!parsed.success) throwProblem(validationFailed([{ path: "input", message: "invalid file part input" }]))
|
||||
const data = dataFor(ctx)
|
||||
const scope = await scopeFor(data, identity, parsed.data, "files.file.write", "files.file.putPart")
|
||||
const row = await data.forScope(scope).of(files).get(parsed.data.fileId)
|
||||
if (
|
||||
!row ||
|
||||
row.uploadedBy !== scope.actor.userId ||
|
||||
row.status !== "pending" ||
|
||||
!row.uploadId ||
|
||||
!row.partSize ||
|
||||
!row.declaredSize
|
||||
) {
|
||||
throwProblem(notFound("no such upload"))
|
||||
}
|
||||
const count = Math.ceil(row.declaredSize / row.partSize)
|
||||
const part = parsed.data.part
|
||||
if (!Number.isInteger(part) || part < 1 || part > count) {
|
||||
throwProblem(validationFailed([{ path: "part", message: `this upload has pieces 1 to ${count}` }]))
|
||||
}
|
||||
return {
|
||||
uploadId: row.uploadId,
|
||||
storageKey: row.storageKey,
|
||||
part,
|
||||
size: Math.min(row.partSize, row.declaredSize - (part - 1) * row.partSize),
|
||||
contentType: part === 1 ? row.contentType : undefined,
|
||||
scope: { orgId: scope.orgId, workspaceId: scope.workspaceId, projectId: scope.projectId },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** File metadata needed by the BFF to stream the private Blob response after authorization. */
|
||||
export const downloadInfo = query({
|
||||
args: { input: v.any() },
|
||||
handler: async (ctx, { input }) => {
|
||||
const identity = await ctx.auth.getUserIdentity()
|
||||
if (!identity) throwProblem(unauthenticated())
|
||||
trustedIdentity(identity)
|
||||
const parsed = fileGet.input.safeParse(input)
|
||||
if (!parsed.success) throwProblem(validationFailed([{ path: "input", message: "invalid file download input" }]))
|
||||
const data = dataFor(ctx)
|
||||
const scope = await scopeFor(data, identity, parsed.data, "files.file.read", "files.file.download", "read")
|
||||
const row = await data.forScope(scope).of(files).get(parsed.data.fileId)
|
||||
if (row?.status !== "stored" || row.size === null) throwProblem(notFound("no such file"))
|
||||
return {
|
||||
storageKey: row.storageKey,
|
||||
filename: row.filename,
|
||||
contentType: row.contentType,
|
||||
size: row.size,
|
||||
scope: { orgId: scope.orgId, workspaceId: scope.workspaceId, projectId: scope.projectId },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const finalizeUpload = internalMutation({
|
||||
args: { identity: identityValidator, input: v.any(), fileId: v.string(), stored: v.any() },
|
||||
handler: async (ctx, { identity, input, fileId, stored }) => {
|
||||
const data = dataFor(ctx)
|
||||
const scope = await scopeFor(data, identity, input, "files.file.write", "files.file.completeUpload")
|
||||
const row = await data.forScope(scope).of(files).get(fileId)
|
||||
if (!row || row.uploadedBy !== scope.actor.userId) throwProblem(notFound("no such upload"))
|
||||
return finalize({ data, store: unavailableObjectStore, policy: DEFAULT_FILES_POLICY }, scope, fileId, {
|
||||
stored,
|
||||
}).catch(throwProblem)
|
||||
},
|
||||
})
|
||||
|
||||
export const markDelete = internalMutation({
|
||||
args: { identity: identityValidator, input: v.any() },
|
||||
handler: async (ctx, { identity, input }) => {
|
||||
const data = dataFor(ctx)
|
||||
const parsed = fileDelete.input.safeParse(input)
|
||||
if (!parsed.success) throwProblem(validationFailed([{ path: "input", message: "invalid file delete input" }]))
|
||||
const scope = await scopeFor(data, identity, parsed.data, "files.file.delete", "files.file.delete", "write")
|
||||
const result = await beginDelete(
|
||||
{ data, store: unavailableObjectStore, policy: DEFAULT_FILES_POLICY },
|
||||
scope,
|
||||
parsed.data.fileId,
|
||||
scope.actor.userId,
|
||||
).catch(throwProblem)
|
||||
return { ...result, scope: { orgId: scope.orgId, workspaceId: scope.workspaceId, projectId: scope.projectId } }
|
||||
},
|
||||
})
|
||||
|
||||
export const finalizeDelete = internalMutation({
|
||||
args: { identity: identityValidator, input: v.any(), fileId: v.string() },
|
||||
handler: async (ctx, { identity, input, fileId }) => {
|
||||
const data = dataFor(ctx)
|
||||
const scope = await scopeFor(data, identity, input, "files.file.delete", "files.file.delete", "write")
|
||||
return finishDeleteRecord(data, scope, fileId)
|
||||
},
|
||||
})
|
||||
|
||||
/** Internal recovery paths do not depend on the initiating member remaining in the project. */
|
||||
const systemFileScope = (ids: { orgId: string; workspaceId: string; projectId: string }): FileScope => ({
|
||||
level: "project",
|
||||
actor: systemActor("files.convex-recovery"),
|
||||
...ids,
|
||||
})
|
||||
|
||||
export const systemDeleteInfo = internalMutation({
|
||||
args: { orgId: v.string(), workspaceId: v.string(), projectId: v.string(), fileId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const data = dataFor(ctx)
|
||||
const row = await data.forScope(systemFileScope(args)).of(files).get(args.fileId)
|
||||
if (row?.status !== "deleting") return null
|
||||
return { storageKey: row.storageKey }
|
||||
},
|
||||
})
|
||||
|
||||
export const systemFinalizeDelete = internalMutation({
|
||||
args: { orgId: v.string(), workspaceId: v.string(), projectId: v.string(), fileId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const data = dataFor(ctx)
|
||||
return finishDeleteRecord(data, systemFileScope(args), args.fileId)
|
||||
},
|
||||
})
|
||||
|
||||
/** The system reconciler only claims pending uploads older than the kit's configured grace. */
|
||||
export const systemPendingInfo = internalMutation({
|
||||
args: { orgId: v.string(), workspaceId: v.string(), projectId: v.string(), fileId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const data = dataFor(ctx)
|
||||
const row = await data.forScope(systemFileScope(args)).of(files).get(args.fileId)
|
||||
const cutoff = Date.now() - DEFAULT_FILES_POLICY.pendingGraceSeconds * 1_000
|
||||
if (row?.status !== "pending" || Date.parse(row.createdAt) > cutoff) {
|
||||
return null
|
||||
}
|
||||
return { storageKey: row.storageKey, declaredSize: row.declaredSize }
|
||||
},
|
||||
})
|
||||
|
||||
export const systemAbandonUpload = internalMutation({
|
||||
args: { orgId: v.string(), workspaceId: v.string(), projectId: v.string(), fileId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const data = dataFor(ctx)
|
||||
return markAbandoned(
|
||||
{ data, store: unavailableObjectStore, policy: DEFAULT_FILES_POLICY },
|
||||
systemFileScope(args),
|
||||
args.fileId,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const systemFinalizeUpload = internalMutation({
|
||||
args: {
|
||||
orgId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
projectId: v.string(),
|
||||
fileId: v.string(),
|
||||
stored: v.object({ size: v.number(), sha256: v.string() }),
|
||||
},
|
||||
handler: async (ctx, { stored, ...args }) => {
|
||||
const data = dataFor(ctx)
|
||||
return finalize(
|
||||
{ data, store: unavailableObjectStore, policy: DEFAULT_FILES_POLICY },
|
||||
systemFileScope(args),
|
||||
args.fileId,
|
||||
{ recovered: true, stored },
|
||||
).catch(throwProblem)
|
||||
},
|
||||
})
|
||||
|
||||
/** Durable backstop for abandoned uploads, process crashes, and failed delete retry actions. */
|
||||
export const recoverRows = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const now = Date.now()
|
||||
const deleting = (await ctx.db
|
||||
.query("files_file")
|
||||
.filter((q) => q.and(q.eq(q.field("status"), "deleting"), q.neq(q.field("deletingAt"), null)))
|
||||
.order("asc")
|
||||
.take(50)) as Array<Record<string, unknown>>
|
||||
for (const row of deleting) {
|
||||
if (
|
||||
typeof row.deletingAt !== "string" ||
|
||||
Date.parse(row.deletingAt) > now ||
|
||||
typeof row.orgId !== "string" ||
|
||||
typeof row.workspaceId !== "string" ||
|
||||
typeof row.projectId !== "string" ||
|
||||
typeof row.id !== "string"
|
||||
)
|
||||
continue
|
||||
await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), {
|
||||
orgId: row.orgId as string,
|
||||
workspaceId: row.workspaceId,
|
||||
projectId: row.projectId,
|
||||
fileId: row.id,
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
const staleUploads = (await ctx.db
|
||||
.query("files_file")
|
||||
.filter((q) => q.eq(q.field("status"), "pending"))
|
||||
.order("asc")
|
||||
.take(50)) as Array<Record<string, unknown>>
|
||||
for (const row of staleUploads) {
|
||||
if (
|
||||
typeof row.createdAt !== "string" ||
|
||||
Date.parse(row.createdAt) > now - DEFAULT_FILES_POLICY.pendingGraceSeconds * 1_000 ||
|
||||
typeof row.orgId !== "string" ||
|
||||
typeof row.workspaceId !== "string" ||
|
||||
typeof row.projectId !== "string" ||
|
||||
typeof row.id !== "string"
|
||||
)
|
||||
continue
|
||||
await ctx.scheduler.runAfter(0, actionRef("filesActions:recoverUpload"), {
|
||||
orgId: row.orgId as string,
|
||||
workspaceId: row.workspaceId as string,
|
||||
projectId: row.projectId as string,
|
||||
fileId: row.id as string,
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
return { scheduledDeletes: deleting.length, scheduledUploads: staleUploads.length }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Object-store effects are Node actions; each pairs with internal mutations in files.ts.
|
||||
|
||||
import { type Actor, AppError, toProblem, unauthenticated, validationFailed } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { DEFAULT_FILES_POLICY, digestStream, platformObjectStore, StreamTooLarge } from "@conusai-kit/files"
|
||||
// The object-store entry alone: the package root also exports Node-only adapters (the audit
|
||||
// client), which a Convex action outside "use node" cannot bundle.
|
||||
import { createObjectStore } from "@conusai-kit/platform/object-store"
|
||||
import { makeFunctionReference, type UserIdentity } from "convex/server"
|
||||
import { ConvexError, type Value, v } from "convex/values"
|
||||
import { action, internalAction } from "./_generated/server"
|
||||
import { CONVEX_BFF_ISSUER } from "./lib/runtime"
|
||||
|
||||
const requiredEnv = (name: string): string => {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Convex deployment is missing ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const objectStore = () => {
|
||||
const apiUrl = requiredEnv("CONUSAI_API_URL")
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(apiUrl)
|
||||
} catch {
|
||||
throw new Error("Convex deployment has an invalid CONUSAI_API_URL")
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Convex deployment has an invalid CONUSAI_API_URL")
|
||||
}
|
||||
return platformObjectStore({
|
||||
blob: createObjectStore({
|
||||
CONUSAI_API_URL: apiUrl,
|
||||
CONUSAI_API_TOKEN: requiredEnv("CONUSAI_API_TOKEN"),
|
||||
CONUSAI_PROJECT_ID: requiredEnv("CONUSAI_PROJECT_ID"),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const trustedIdentity = (identity: UserIdentity): Actor => {
|
||||
if (identity.issuer !== CONVEX_BFF_ISSUER || !identity.subject) {
|
||||
throwProblem(unauthenticated("file actions accept only an authenticated app BFF identity"))
|
||||
}
|
||||
return { userId: identity.subject }
|
||||
}
|
||||
|
||||
function throwProblem(error: unknown): never {
|
||||
if (error instanceof AppError) throw new ConvexError(toProblem(error) as unknown as Value)
|
||||
throw error
|
||||
}
|
||||
|
||||
const identityForMutation = (identity: UserIdentity) => ({
|
||||
issuer: identity.issuer,
|
||||
subject: identity.subject,
|
||||
tokenIdentifier: identity.tokenIdentifier,
|
||||
...("conusaiOrgId" in identity && typeof identity.conusaiOrgId === "string"
|
||||
? { conusaiOrgId: identity.conusaiOrgId }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const identityValidator = v.object({
|
||||
issuer: v.string(),
|
||||
subject: v.string(),
|
||||
tokenIdentifier: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
name: v.optional(v.string()),
|
||||
conusaiOrgId: v.optional(v.string()),
|
||||
})
|
||||
const mutationRef = (name: string) => makeFunctionReference<"mutation">(name)
|
||||
const actionRef = (name: string) => makeFunctionReference<"action">(name)
|
||||
|
||||
/** Called by web readiness to prove that the deployed backend can execute its file action module. */
|
||||
export const readiness = action({
|
||||
args: {},
|
||||
handler: async () => {
|
||||
objectStore()
|
||||
requiredEnv("CONUSAI_API_TOKEN")
|
||||
requiredEnv("CONUSAI_PROJECT_ID")
|
||||
return { ready: true }
|
||||
},
|
||||
})
|
||||
|
||||
export const begin = action({
|
||||
args: { input: v.any() },
|
||||
handler: async (ctx, { input }) => {
|
||||
const identity = await ctx.auth.getUserIdentity()
|
||||
if (!identity) throwProblem(unauthenticated())
|
||||
trustedIdentity(identity)
|
||||
const safeIdentity = identityForMutation(identity)
|
||||
const started = await ctx.runMutation(mutationRef("files:beginIntent"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
} as never)
|
||||
const plan = await objectStore()
|
||||
.beginUpload(
|
||||
{ level: "project", actor: trustedIdentity(identity), ...started.scope },
|
||||
{ key: started.intent.storageKey, contentType: started.intent.contentType, size: started.intent.declaredSize },
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof AppError) throwProblem(error)
|
||||
throw new Error("the platform could not open a file upload session")
|
||||
})
|
||||
const pending = await ctx.runMutation(mutationRef("files:saveUploadSession"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
intent: started.intent,
|
||||
plan,
|
||||
} as never)
|
||||
return { fileId: pending.fileId, parts: pending.parts, expiresAt: pending.expiresAt }
|
||||
},
|
||||
})
|
||||
|
||||
export const complete = action({
|
||||
args: { input: v.any() },
|
||||
handler: async (ctx, { input }) => {
|
||||
const identity = await ctx.auth.getUserIdentity()
|
||||
if (!identity) throwProblem(unauthenticated())
|
||||
trustedIdentity(identity)
|
||||
const safeIdentity = identityForMutation(identity)
|
||||
const info = await ctx.runMutation(mutationRef("files:completeInfo"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
} as never)
|
||||
if (info.alreadyStored) return info.result
|
||||
const scope = { level: "project" as const, actor: trustedIdentity(identity), ...info.scope }
|
||||
let stored: { size: number; sha256: string }
|
||||
try {
|
||||
stored = await objectStore().completeUpload(scope, {
|
||||
key: info.storageKey,
|
||||
uploadId: info.uploadId,
|
||||
parts: input.parts,
|
||||
})
|
||||
} catch (error) {
|
||||
// A lost response after Blob completed is settled by reading the durable object. Repeating
|
||||
// finalize is safe, and a missing object preserves the original failure.
|
||||
const existing = await objectStore()
|
||||
.get(scope, info.storageKey)
|
||||
.catch(() => null)
|
||||
if (!existing) throwProblem(error)
|
||||
const digest = await digestStream(existing.body, DEFAULT_FILES_POLICY.maxBytes)
|
||||
stored = { size: digest.size, sha256: digest.sha256 }
|
||||
}
|
||||
if (stored.size !== info.declaredSize) {
|
||||
throwProblem(
|
||||
validationFailed([{ path: "size", message: "the stored file does not match the size declared for it" }]),
|
||||
)
|
||||
}
|
||||
return ctx.runMutation(mutationRef("files:finalizeUpload"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
fileId: info.fileId,
|
||||
stored,
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = action({
|
||||
args: { input: v.any() },
|
||||
handler: async (ctx, { input }) => {
|
||||
const identity = await ctx.auth.getUserIdentity()
|
||||
if (!identity) throwProblem(unauthenticated())
|
||||
trustedIdentity(identity)
|
||||
const safeIdentity = identityForMutation(identity)
|
||||
const marked = await ctx.runMutation(mutationRef("files:markDelete"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
} as never)
|
||||
try {
|
||||
const scope = { level: "project" as const, actor: trustedIdentity(identity), ...marked.scope }
|
||||
await objectStore().delete(scope, marked.storageKey)
|
||||
await ctx.runMutation(mutationRef("files:finalizeDelete"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
fileId: marked.fileId,
|
||||
} as never)
|
||||
return null
|
||||
} catch (error) {
|
||||
await ctx.scheduler.runAfter(1_000, actionRef("filesActions:retryDelete"), {
|
||||
identity: safeIdentity,
|
||||
input,
|
||||
attempt: 0,
|
||||
})
|
||||
throwProblem(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const retryDelete = internalAction({
|
||||
args: { identity: identityValidator, input: v.any(), attempt: v.number() },
|
||||
handler: async (ctx, { identity, input, attempt }) => {
|
||||
try {
|
||||
const marked = await ctx.runMutation(mutationRef("files:markDelete"), { identity, input } as never)
|
||||
const scope = { level: "project" as const, actor: trustedIdentity(identity), ...marked.scope }
|
||||
await objectStore().delete(scope, marked.storageKey)
|
||||
await ctx.runMutation(mutationRef("files:finalizeDelete"), {
|
||||
identity,
|
||||
input,
|
||||
fileId: marked.fileId,
|
||||
} as never)
|
||||
} catch (error) {
|
||||
if (attempt < 8) {
|
||||
await ctx.scheduler.runAfter(Math.min(300_000, 1_000 * 2 ** attempt), actionRef("filesActions:retryDelete"), {
|
||||
identity,
|
||||
input,
|
||||
attempt: attempt + 1,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const cleanupDelete = internalAction({
|
||||
args: {
|
||||
orgId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
projectId: v.string(),
|
||||
fileId: v.string(),
|
||||
attempt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
const { attempt, ...ids } = args
|
||||
const info = await ctx.runMutation(mutationRef("files:systemDeleteInfo"), ids as never)
|
||||
if (!info) return
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
actor: systemActor("files.convex-recovery"),
|
||||
orgId: args.orgId,
|
||||
workspaceId: args.workspaceId,
|
||||
projectId: args.projectId,
|
||||
}
|
||||
await objectStore().delete(scope, info.storageKey)
|
||||
await ctx.runMutation(mutationRef("files:systemFinalizeDelete"), ids as never)
|
||||
} catch (error) {
|
||||
if (args.attempt < 8) {
|
||||
await ctx.scheduler.runAfter(
|
||||
Math.min(300_000, 1_000 * 2 ** args.attempt),
|
||||
actionRef("filesActions:cleanupDelete"),
|
||||
{ ...args, attempt: args.attempt + 1 },
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Reconcile an upload whose request may have crashed at any boundary after its row was created. */
|
||||
export const recoverUpload = internalAction({
|
||||
args: {
|
||||
orgId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
projectId: v.string(),
|
||||
fileId: v.string(),
|
||||
attempt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { attempt, ...ids } = args
|
||||
try {
|
||||
const info = await ctx.runMutation(mutationRef("files:systemPendingInfo"), ids as never)
|
||||
if (!info) return
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
actor: systemActor("files.convex-recovery"),
|
||||
orgId: args.orgId,
|
||||
workspaceId: args.workspaceId,
|
||||
projectId: args.projectId,
|
||||
}
|
||||
const stored = await objectStore().get(scope, info.storageKey)
|
||||
if (!stored || info.declaredSize === null) {
|
||||
await ctx.runMutation(mutationRef("files:systemAbandonUpload"), ids as never)
|
||||
await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), {
|
||||
...ids,
|
||||
attempt: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
let digest: { size: number; sha256: string }
|
||||
try {
|
||||
digest = await digestStream(stored.body, DEFAULT_FILES_POLICY.maxBytes)
|
||||
} catch (error) {
|
||||
if (!(error instanceof StreamTooLarge)) throw error
|
||||
await ctx.runMutation(mutationRef("files:systemAbandonUpload"), ids as never)
|
||||
await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), {
|
||||
...ids,
|
||||
attempt: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (digest.size !== info.declaredSize) {
|
||||
await ctx.runMutation(mutationRef("files:systemAbandonUpload"), ids as never)
|
||||
await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), {
|
||||
...ids,
|
||||
attempt: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
await ctx.runMutation(mutationRef("files:systemFinalizeUpload"), {
|
||||
...ids,
|
||||
stored: digest,
|
||||
} as never)
|
||||
} catch (error) {
|
||||
if (attempt < 8) {
|
||||
await ctx.scheduler.runAfter(Math.min(300_000, 1_000 * 2 ** attempt), actionRef("filesActions:recoverUpload"), {
|
||||
...args,
|
||||
attempt: attempt + 1,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: account. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantAction, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const account_data_export = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("account.data.export", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const account_deletion_request = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("account.deletion.request", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const account_deletion_status = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("account.deletion.status", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: auth. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantMutation, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const auth_apiKey_create = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("auth.apiKey.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const auth_apiKey_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("auth.apiKey.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const auth_apiKey_revoke = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("auth.apiKey.revoke", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const auth_session_current = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("auth.session.current", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: branding. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantMutation, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const branding_brand_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("branding.brand.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const branding_brand_set = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("branding.brand.set", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: documents. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantMutation, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const documents_document_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("documents.document.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const documents_document_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("documents.document.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const documents_line_edit = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("documents.line.edit", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: files. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantQuery } from "../lib/functions";
|
||||
|
||||
export const files_file_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("files.file.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const files_file_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("files.file.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: guides. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantAction } from "../lib/functions";
|
||||
|
||||
export const guides_builder_open = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("guides.builder.open", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const guides_runtime_bootstrap = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("guides.runtime.bootstrap", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const guides_runtime_deleteFile = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("guides.runtime.deleteFile", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const guides_runtime_record = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("guides.runtime.record", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: notes. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantMutation, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const notes_attachment_add = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.attachment.add", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const notes_attachment_remove = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.attachment.remove", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const notes_note_create = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.note.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const notes_note_delete = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.note.delete", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const notes_note_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.note.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const notes_note_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.note.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const notes_note_update = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("notes.note.update", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand.
|
||||
export const CONVEX_PROCEDURES = {
|
||||
"account.data.export": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/account:account_data_export"
|
||||
},
|
||||
"account.deletion.request": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/account:account_deletion_request"
|
||||
},
|
||||
"account.deletion.status": {
|
||||
"kind": "read",
|
||||
"reference": "generated/account:account_deletion_status"
|
||||
},
|
||||
"auth.apiKey.create": {
|
||||
"kind": "write",
|
||||
"reference": "generated/auth:auth_apiKey_create"
|
||||
},
|
||||
"auth.apiKey.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/auth:auth_apiKey_list"
|
||||
},
|
||||
"auth.apiKey.revoke": {
|
||||
"kind": "write",
|
||||
"reference": "generated/auth:auth_apiKey_revoke"
|
||||
},
|
||||
"auth.session.current": {
|
||||
"kind": "read",
|
||||
"reference": "generated/auth:auth_session_current"
|
||||
},
|
||||
"branding.brand.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/branding:branding_brand_get"
|
||||
},
|
||||
"branding.brand.set": {
|
||||
"kind": "write",
|
||||
"reference": "generated/branding:branding_brand_set"
|
||||
},
|
||||
"documents.document.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/documents:documents_document_get"
|
||||
},
|
||||
"documents.document.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/documents:documents_document_list"
|
||||
},
|
||||
"documents.line.edit": {
|
||||
"kind": "write",
|
||||
"reference": "generated/documents:documents_line_edit"
|
||||
},
|
||||
"files.file.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/files:files_file_get"
|
||||
},
|
||||
"files.file.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/files:files_file_list"
|
||||
},
|
||||
"guides.builder.open": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/guides:guides_builder_open"
|
||||
},
|
||||
"guides.runtime.bootstrap": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/guides:guides_runtime_bootstrap"
|
||||
},
|
||||
"guides.runtime.deleteFile": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/guides:guides_runtime_deleteFile"
|
||||
},
|
||||
"guides.runtime.record": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/guides:guides_runtime_record"
|
||||
},
|
||||
"notes.attachment.add": {
|
||||
"kind": "write",
|
||||
"reference": "generated/notes:notes_attachment_add"
|
||||
},
|
||||
"notes.attachment.remove": {
|
||||
"kind": "write",
|
||||
"reference": "generated/notes:notes_attachment_remove"
|
||||
},
|
||||
"notes.note.create": {
|
||||
"kind": "write",
|
||||
"reference": "generated/notes:notes_note_create"
|
||||
},
|
||||
"notes.note.delete": {
|
||||
"kind": "write",
|
||||
"reference": "generated/notes:notes_note_delete"
|
||||
},
|
||||
"notes.note.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/notes:notes_note_get"
|
||||
},
|
||||
"notes.note.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/notes:notes_note_list"
|
||||
},
|
||||
"notes.note.update": {
|
||||
"kind": "write",
|
||||
"reference": "generated/notes:notes_note_update"
|
||||
},
|
||||
"proposals.proposal.approve": {
|
||||
"kind": "write",
|
||||
"reference": "generated/proposals:proposals_proposal_approve"
|
||||
},
|
||||
"proposals.proposal.approveMany": {
|
||||
"kind": "write",
|
||||
"reference": "generated/proposals:proposals_proposal_approveMany"
|
||||
},
|
||||
"proposals.proposal.create": {
|
||||
"kind": "write",
|
||||
"reference": "generated/proposals:proposals_proposal_create"
|
||||
},
|
||||
"proposals.proposal.edit": {
|
||||
"kind": "write",
|
||||
"reference": "generated/proposals:proposals_proposal_edit"
|
||||
},
|
||||
"proposals.proposal.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/proposals:proposals_proposal_get"
|
||||
},
|
||||
"proposals.proposal.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/proposals:proposals_proposal_list"
|
||||
},
|
||||
"proposals.proposal.reject": {
|
||||
"kind": "write",
|
||||
"reference": "generated/proposals:proposals_proposal_reject"
|
||||
},
|
||||
"support.widget.config": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/support:support_widget_config"
|
||||
},
|
||||
"support.widgetSession.mint": {
|
||||
"kind": "effect",
|
||||
"reference": "generated/support:support_widgetSession_mint"
|
||||
},
|
||||
"tenancy.invitation.accept": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_invitation_accept"
|
||||
},
|
||||
"tenancy.invitation.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_invitation_list"
|
||||
},
|
||||
"tenancy.me.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_me_get"
|
||||
},
|
||||
"tenancy.me.update": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_me_update"
|
||||
},
|
||||
"tenancy.member.change-role": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_member_change_role"
|
||||
},
|
||||
"tenancy.member.invite": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_member_invite"
|
||||
},
|
||||
"tenancy.member.leave": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_member_leave"
|
||||
},
|
||||
"tenancy.member.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_member_list"
|
||||
},
|
||||
"tenancy.member.remove": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_member_remove"
|
||||
},
|
||||
"tenancy.org.create": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_org_create"
|
||||
},
|
||||
"tenancy.org.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_org_get"
|
||||
},
|
||||
"tenancy.org.update": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_org_update"
|
||||
},
|
||||
"tenancy.project.create": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_project_create"
|
||||
},
|
||||
"tenancy.project.get": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_project_get"
|
||||
},
|
||||
"tenancy.project.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_project_list"
|
||||
},
|
||||
"tenancy.workspace.create": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_workspace_create"
|
||||
},
|
||||
"tenancy.workspace.list": {
|
||||
"kind": "read",
|
||||
"reference": "generated/tenancy:tenancy_workspace_list"
|
||||
},
|
||||
"tenancy.workspace.set-member": {
|
||||
"kind": "write",
|
||||
"reference": "generated/tenancy:tenancy_workspace_set_member"
|
||||
}
|
||||
} as const
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: proposals. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantMutation, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const proposals_proposal_approve = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.approve", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const proposals_proposal_approveMany = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.approveMany", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const proposals_proposal_create = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const proposals_proposal_edit = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.edit", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const proposals_proposal_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const proposals_proposal_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const proposals_proposal_reject = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("proposals.proposal.reject", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: support. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantAction } from "../lib/functions";
|
||||
|
||||
export const support_widget_config = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("support.widget.config", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const support_widgetSession_mint = tenantAction({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("support.widgetSession.mint", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
// Module: tenancy. One Convex function per procedure (spec §5.4): read → query, write →
|
||||
// mutation, effect → action. ctx.run is the custom-function ctx of convex/lib/functions.ts: it
|
||||
// resolves the tenant scope, checks the permission and runs the procedure's own handler with the
|
||||
// module's repositories bound to this function's ctx.db.
|
||||
import { v } from "convex/values";
|
||||
import { tenantMutation, tenantQuery } from "../lib/functions";
|
||||
|
||||
export const tenancy_invitation_accept = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.invitation.accept", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_invitation_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.invitation.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_me_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.me.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_me_update = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.me.update", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_member_change_role = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.member.change-role", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_member_invite = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.member.invite", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_member_leave = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.member.leave", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_member_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.member.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_member_remove = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.member.remove", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_org_create = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.org.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_org_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.org.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_org_update = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.org.update", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_project_create = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.project.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_project_get = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.project.get", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_project_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.project.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_workspace_create = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.workspace.create", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_workspace_list = tenantQuery({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.workspace.list", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
|
||||
export const tenancy_workspace_set_member = tenantMutation({
|
||||
args: { input: v.optional(v.any()), idempotencyKey: v.optional(v.string()) },
|
||||
handler: async (ctx, { input, idempotencyKey }) => ctx.run("tenancy.workspace.set-member", input, idempotencyKey === undefined ? undefined : { idempotencyKey }),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
export interface ConvexModuleLike {
|
||||
readonly id: string
|
||||
readonly procedures?: readonly { readonly name: string; readonly kind: string }[]
|
||||
}
|
||||
|
||||
export const CONVEX_EXCLUDED_MODULES: Map<string, string>
|
||||
export const CONVEX_EXCLUDED_PROCEDURES: Map<string, string>
|
||||
export const CONVEX_ACTION_EFFECT_MODULES: Map<string, string>
|
||||
|
||||
export function convexModules<T extends ConvexModuleLike>(appModules: readonly T[]): T[]
|
||||
export function convexBffModules<T extends ConvexModuleLike>(appModules: readonly T[]): T[]
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The modules that deploy to the Convex backend on this profile, derived from the app's one
|
||||
// module list (modules/index.ts) -- named exclusions, not a second list.
|
||||
//
|
||||
// Why a module is excluded: its callable procedure graph needs a Node-only runtime or a workflow
|
||||
// that has not been composed here. These are explicit gaps, not inferred from the module
|
||||
// importing a package which has unrelated Node-only exports. Auth is included: its procedure
|
||||
// handlers use Web Crypto; the OIDC redirects and sealed session storage remain in the trusted
|
||||
// Next BFF, which invokes Convex functions with server-only admin auth and a verified identity.
|
||||
//
|
||||
// Convex actions have no ctx.db, so the runtime cannot run an effect procedure that changes the
|
||||
// app's own rows in an action. File upload and delete have explicit action→mutation compositions
|
||||
// in files.ts; other effects remain omitted until they have an equivalent state-machine workflow.
|
||||
export const CONVEX_EXCLUDED_MODULES = new Map([])
|
||||
|
||||
// The modules whose effect procedures are generated as Convex actions (convex/lib/effects.ts):
|
||||
// each effect is one call to a platform service through the module's own port and owns no app
|
||||
// row, so it needs no transaction -- except the account module's, whose rows are reached through
|
||||
// its bound store, one transaction per resumable step. The kernel still resolves the tenant scope and checks the
|
||||
// permission, against ctx.db in an internal query (convex/effectScope.ts), and the handler's
|
||||
// nested reads and writes run as the generated queries and mutations they always are.
|
||||
export const CONVEX_ACTION_EFFECT_MODULES = new Map()
|
||||
CONVEX_ACTION_EFFECT_MODULES.set("guides", "Guides runtime calls through GUIDES_PLATFORM; no app rows") // kit:guides
|
||||
CONVEX_ACTION_EFFECT_MODULES.set("support", "Support session/contact calls through SUPPORT_PLATFORM") // kit:support
|
||||
// The account module's export and deletion compose every module's share (platform calls) and own the
|
||||
// deletion ledger, which they reach through ACCOUNT_STORE: one internal query or mutation per step.
|
||||
CONVEX_ACTION_EFFECT_MODULES.set("account", "account export/deletion; ledger steps through convex/account.ts")
|
||||
|
||||
export const CONVEX_EXCLUDED_PROCEDURES = new Map([
|
||||
["branding.logo.set", "logo decoding and Blob writes require an audited action workflow"],
|
||||
["branding.logo.clear", "logo record updates and concurrent Blob cleanup require an action workflow"],
|
||||
["branding.logo.get", "logo bytes require an authorized Blob action workflow"],
|
||||
["files.file.delete", "the BFF composes the object delete action with internal mutations"],
|
||||
])
|
||||
// The account page's platform analytics erasure and export (ADR-049) call the platform with the
|
||||
// deployment token through ACCOUNT_PRIVACY, which only the web's composition root binds; no Convex
|
||||
// action binds it yet, so on this profile the panel answers "unavailable".
|
||||
for (const name of ["account.analytics.request", "account.analytics.status", "account.analytics.export"]) {
|
||||
CONVEX_EXCLUDED_PROCEDURES.set(name, "ACCOUNT_PRIVACY is bound by the web's composition root only")
|
||||
}
|
||||
// kit:guides:begin -- a survey file's bytes are the streamed body of a PUT, which cannot be a Convex
|
||||
// action's argument, and no BFF composition streams it to the platform on this profile yet.
|
||||
CONVEX_EXCLUDED_PROCEDURES.set(
|
||||
"guides.runtime.uploadFile",
|
||||
"a streamed request body cannot be a Convex action argument",
|
||||
)
|
||||
// kit:guides:end
|
||||
|
||||
const modulesFor = (appModules, includeBffFileEffects) =>
|
||||
appModules
|
||||
.filter((module) => !CONVEX_EXCLUDED_MODULES.has(module.id))
|
||||
.map((module) => ({
|
||||
...module,
|
||||
procedures: (module.procedures ?? []).filter((procedure) => {
|
||||
const composedBffFileProcedure =
|
||||
includeBffFileEffects && module.id === "files" && procedure.name.startsWith("files.file.")
|
||||
const runnable = procedure.kind !== "effect" || CONVEX_ACTION_EFFECT_MODULES.has(module.id)
|
||||
return composedBffFileProcedure || (runnable && !CONVEX_EXCLUDED_PROCEDURES.has(procedure.name))
|
||||
}),
|
||||
}))
|
||||
|
||||
// The backend generator and deployment registry omit procedures that are composed as actions by
|
||||
// the BFF. The web HTTP registry keeps those file contracts so their routes still validate input,
|
||||
// tenancy, permissions, rate limits and CSRF before the caller invokes the action workflow.
|
||||
export const convexModules = (appModules) => modulesFor(appModules, false)
|
||||
export const convexBffModules = (appModules) => modulesFor(appModules, true)
|
||||
@@ -0,0 +1,370 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Effect procedures on the Convex profile (spec §5.4: effect → action). An action has no ctx.db,
|
||||
// so it cannot hold a transaction; the modules of CONVEX_ACTION_EFFECT_MODULES (lib/convexModules)
|
||||
// need none, because each effect is one call to a platform service through the module's own port
|
||||
// and owns no app row. The kernel runs here exactly as it does on every profile -- input
|
||||
// validation, scope, permission, the kind rule, the handler, output validation -- with three
|
||||
// bindings that reach the backend instead of a local database:
|
||||
//
|
||||
// tenancy resolveScope and authorize run as internal queries (effectScope.ts) with the same
|
||||
// tenancy hooks over ctx.db, under the action's own verified identity;
|
||||
// nested a read or write the handler calls (`tenancy.me.get` for a trait, say) runs as its
|
||||
// generated query or mutation, so it is a real Convex transaction with its own checks;
|
||||
// ports GUIDES_PLATFORM and SUPPORT_PLATFORM are bound from the deployment variables the
|
||||
// deploy job installs in the backend (profiles/convex/deploy.mjs), like the Blob
|
||||
// client of filesActions.ts.
|
||||
// account the account module's export and deletion do own app rows -- the deletion ledger and
|
||||
// the person's own -- so their store is bound as ACCOUNT_STORE, each method one
|
||||
// internal query or mutation of convex/account.ts (one transaction per step: the
|
||||
// deletion is a resumable ledger of idempotent steps by design), with the caller's
|
||||
// ACCOUNT_CREDENTIAL from the BFF's identity and, with Better Auth, the provider whose
|
||||
// account a deletion ends. The sign-out revocation outbox is bound the same way
|
||||
// (REVOCATION_STORE): `account:revokeSession` records a revoked app session and delivers
|
||||
// it from this container, and the one-minute cron `account:revocationSweep` retries it.
|
||||
//
|
||||
// The platform token never leaves the backend, and the browser still reaches these procedures only
|
||||
// through the BFF, which calls the generated action with the admin key and the member's identity.
|
||||
|
||||
import { betterAuthAccountEndFromEnv } from "@conusai-kit/auth-convex-better-auth/account" // kit:convex-better-auth
|
||||
import {
|
||||
type AnyProcedure,
|
||||
type AnyScope,
|
||||
AppError,
|
||||
buildRegistry,
|
||||
type CallOptions,
|
||||
Container,
|
||||
createCaller,
|
||||
implement,
|
||||
internal,
|
||||
type ModuleManifest,
|
||||
type ProcedureKind,
|
||||
type ScopeOf,
|
||||
type TenancyHooks,
|
||||
type TenantLevel,
|
||||
token,
|
||||
toProblem,
|
||||
} from "@conusai-kit/core"
|
||||
import { GUIDES_PLATFORM, GUIDES_REGISTRY, guidesPlatform } from "@conusai-kit/guides" // kit:guides
|
||||
import { SUPPORT_PLATFORM, supportPlatform } from "@conusai-kit/support" // kit:support
|
||||
import { REQUEST_SESSION } from "@conusai-kit/tenancy"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { ConvexError, type Value } from "convex/values"
|
||||
import {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_IDENTITY_PROVIDER, // kit:convex-better-auth
|
||||
ACCOUNT_REGISTRY,
|
||||
ACCOUNT_STORE,
|
||||
type AccountCredential,
|
||||
type AccountStore,
|
||||
REVOCATION_JOB,
|
||||
REVOCATION_STORE,
|
||||
type RevocationStore,
|
||||
recordSessionRevocation,
|
||||
} from "../../modules/dist/account/index.js"
|
||||
import { CONVEX_ACTION_EFFECT_MODULES } from "./convexModules.mjs"
|
||||
import { callContextOf, deployableModules, registry } from "./runtime"
|
||||
|
||||
/** What an action handler gets from Convex that this file uses. */
|
||||
export interface EffectActionCtx {
|
||||
auth: { getUserIdentity(): Promise<import("convex/server").UserIdentity | null> }
|
||||
runQuery(reference: never, args: never): Promise<unknown>
|
||||
runMutation(reference: never, args: never): Promise<unknown>
|
||||
}
|
||||
|
||||
/** The tenant ids of a scope, which is what crosses into the internal query and back. */
|
||||
export interface ScopeIds {
|
||||
orgId: string
|
||||
workspaceId?: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
const functionName = (name: string): string => name.replace(/[^A-Za-z0-9_$]/g, "_")
|
||||
|
||||
/** A backend refusal, back as the kernel's own problem; anything else stays opaque. */
|
||||
const asAppError = (error: unknown): unknown => {
|
||||
if (error instanceof ConvexError && typeof error.data === "object" && error.data !== null) {
|
||||
const value = error.data as Record<string, unknown>
|
||||
if (typeof value.status === "number" && typeof value.code === "string" && typeof value.title === "string") {
|
||||
return new AppError(value as unknown as ConstructorParameters<typeof AppError>[0])
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
async function backend<T>(run: () => Promise<unknown>): Promise<T> {
|
||||
try {
|
||||
return (await run()) as T
|
||||
} catch (error) {
|
||||
throw asAppError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a nested read or write procedure as its generated Convex function. */
|
||||
interface NestedProcedures {
|
||||
run(name: string, kind: ProcedureKind, input: unknown, idempotencyKey: string | undefined): Promise<unknown>
|
||||
}
|
||||
|
||||
// A read port: a nested call is made from a read, write or effect handler alike, and the kernel has
|
||||
// already enforced the kind rule for it before this handler runs.
|
||||
const NESTED = token<NestedProcedures>("convex.nested-procedures", { access: "read" })
|
||||
|
||||
const forwarding = (def: AnyProcedure): AnyProcedure =>
|
||||
implement(def.contract, async ({ input, ctx }) =>
|
||||
ctx.container.resolve(NESTED).run(def.name, def.kind, input, ctx.idempotencyKey),
|
||||
) as AnyProcedure
|
||||
|
||||
/**
|
||||
* The registry of an effect action: the same modules, contracts, permissions and extension points,
|
||||
* with every read and write procedure forwarding to its generated function. Effect procedures keep
|
||||
* their own handlers, and only those of CONVEX_ACTION_EFFECT_MODULES are ever called here.
|
||||
*/
|
||||
const actionRegistry = buildRegistry(
|
||||
deployableModules.map(
|
||||
(module): ModuleManifest => ({
|
||||
...module,
|
||||
procedures: (module.procedures ?? []).map((def) => (def.kind === "effect" ? def : forwarding(def))),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const referenceOf = new Map(
|
||||
[...registry.procedures.values()].map(({ module, def }) => [
|
||||
def.name,
|
||||
`generated/${module}:${functionName(def.name)}`,
|
||||
]),
|
||||
)
|
||||
|
||||
function nestedProcedures(ctx: EffectActionCtx): NestedProcedures {
|
||||
return {
|
||||
run: (name, kind, input, idempotencyKey) => {
|
||||
const reference = referenceOf.get(name)
|
||||
if (!reference) throw internal(`no generated Convex function for ${name}`)
|
||||
if (kind === "read") {
|
||||
return backend(() => ctx.runQuery(makeFunctionReference<"query">(reference) as never, { input } as never))
|
||||
}
|
||||
if (kind === "write") {
|
||||
return backend(() =>
|
||||
ctx.runMutation(
|
||||
makeFunctionReference<"mutation">(reference) as never,
|
||||
{ input, ...(idempotencyKey === undefined ? {} : { idempotencyKey }) } as never,
|
||||
),
|
||||
)
|
||||
}
|
||||
throw internal(`a nested effect procedure ${name} runs in this action, never as another function`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const idsOf = (scope: AnyScope): ScopeIds => {
|
||||
const value = scope as AnyScope & Partial<ScopeIds>
|
||||
if (typeof value.orgId !== "string") throw internal("an effect action authorizes tenant scopes only")
|
||||
return {
|
||||
orgId: value.orgId,
|
||||
...(value.workspaceId === undefined ? {} : { workspaceId: value.workspaceId }),
|
||||
...(value.projectId === undefined ? {} : { projectId: value.projectId }),
|
||||
}
|
||||
}
|
||||
|
||||
/** The tenancy hooks, run as internal queries against ctx.db under the action's identity. */
|
||||
function backendTenancy(ctx: EffectActionCtx): TenancyHooks {
|
||||
return {
|
||||
resolveScope: async <L extends TenantLevel>(level: L, input: unknown, actor: ScopeOf<L>["actor"]) => {
|
||||
const ids = await backend<ScopeIds>(() =>
|
||||
ctx.runQuery(makeFunctionReference<"query">("effectScope:resolve") as never, { level, input } as never),
|
||||
)
|
||||
// The kernel's own actor object: it checks that the resolver did not swap it.
|
||||
return { level, actor, ...ids } as unknown as ScopeOf<L>
|
||||
},
|
||||
authorize: (scope, permission, procedure) =>
|
||||
backend<boolean>(() =>
|
||||
ctx.runQuery(
|
||||
makeFunctionReference<"query">("effectScope:authorize") as never,
|
||||
{ level: scope.level, scope: idsOf(scope), permission, procedure } as never,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform deployment variables the deploy job installs in the backend: the URL and the app's
|
||||
* token, required unless the backend runs against the fake platform (development and tests).
|
||||
*/
|
||||
export function platformEnv(): { apiUrl: string; token: string; fake: boolean } {
|
||||
const fake = ["1", "true"].includes(process.env.CONUSAI_FAKE_PLATFORM?.trim().toLowerCase() ?? "")
|
||||
const read = (name: string): string => {
|
||||
const value = process.env[name]?.trim() ?? ""
|
||||
if (!value && !fake) throw new Error(`Convex deployment is missing ${name}`)
|
||||
return value
|
||||
}
|
||||
return { apiUrl: read("CONUSAI_API_URL"), token: read("CONUSAI_API_TOKEN"), fake }
|
||||
}
|
||||
|
||||
/** The account store's methods by the transaction they need: a read is a query, a change a mutation. */
|
||||
export const ACCOUNT_STORE_READS = [
|
||||
"emailOf",
|
||||
"sessionSignedInAt",
|
||||
"memberships",
|
||||
"localExport",
|
||||
"deletion",
|
||||
"openDeletions",
|
||||
"identities",
|
||||
] as const
|
||||
export const ACCOUNT_STORE_WRITES = [
|
||||
"startDeletion",
|
||||
"changeDeletion",
|
||||
"revokeCredentials",
|
||||
"detachIdentity",
|
||||
"removeLocalData",
|
||||
] as const
|
||||
type StoreMethod = (typeof ACCOUNT_STORE_READS)[number] | (typeof ACCOUNT_STORE_WRITES)[number]
|
||||
|
||||
/**
|
||||
* The account store of an action: each method is one internal query or mutation of convex/account.ts
|
||||
* over ctx.db, so every step of an export or a deletion is its own Convex transaction. A refusal comes
|
||||
* back as the kernel's own problem (a conflict, a not-found), as from any other backend function.
|
||||
*/
|
||||
export function convexAccountStore(ctx: EffectActionCtx): AccountStore {
|
||||
const read =
|
||||
(method: StoreMethod) =>
|
||||
(...args: unknown[]) =>
|
||||
backend(() =>
|
||||
ctx.runQuery(makeFunctionReference<"query">("account:storeRead") as never, { method, args } as never),
|
||||
)
|
||||
const write =
|
||||
(method: StoreMethod) =>
|
||||
(...args: unknown[]) =>
|
||||
backend(() =>
|
||||
ctx.runMutation(makeFunctionReference<"mutation">("account:storeWrite") as never, { method, args } as never),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
...ACCOUNT_STORE_READS.map((method) => [method, read(method)]),
|
||||
...ACCOUNT_STORE_WRITES.map((method) => [method, write(method)]),
|
||||
]) as unknown as AccountStore
|
||||
}
|
||||
|
||||
/** The revocation outbox's methods by the transaction they need, like the account store's. */
|
||||
export const REVOCATION_STORE_READS = ["due"] as const
|
||||
export const REVOCATION_STORE_WRITES = ["record", "change", "purge"] as const
|
||||
type RevocationMethod = (typeof REVOCATION_STORE_READS)[number] | (typeof REVOCATION_STORE_WRITES)[number]
|
||||
|
||||
/**
|
||||
* The revocation outbox of an action: each method is one internal query or mutation of
|
||||
* convex/account.ts over ctx.db, the Postgres profile's own store (adapters/revocations.ts). A claim
|
||||
* or a settlement is one versioned write, so two actions never deliver one row at once.
|
||||
*/
|
||||
export function convexRevocationStore(ctx: EffectActionCtx): RevocationStore {
|
||||
const read =
|
||||
(method: RevocationMethod) =>
|
||||
(...args: unknown[]) =>
|
||||
backend(() =>
|
||||
ctx.runQuery(makeFunctionReference<"query">("account:revocationRead") as never, { method, args } as never),
|
||||
)
|
||||
const write =
|
||||
(method: RevocationMethod) =>
|
||||
(...args: unknown[]) =>
|
||||
backend(() =>
|
||||
ctx.runMutation(
|
||||
makeFunctionReference<"mutation">("account:revocationWrite") as never,
|
||||
{ method, args } as never,
|
||||
),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
...REVOCATION_STORE_READS.map((method) => [method, read(method)]),
|
||||
...REVOCATION_STORE_WRITES.map((method) => [method, write(method)]),
|
||||
]) as unknown as RevocationStore
|
||||
}
|
||||
|
||||
const backendLog = { warn: (message: string) => console.warn(message) }
|
||||
|
||||
/**
|
||||
* A revoked app session, recorded in the outbox and delivered at once to every module that minted
|
||||
* derived service sessions for it (`account:revokeSession`, called by the web's sign-out). Answers
|
||||
* once the row is written and the first bounded attempt settled; throws only when nothing was
|
||||
* recorded, so the web asks the modules itself.
|
||||
*/
|
||||
export function revokeSessionInBackend(ctx: EffectActionCtx, session: string): Promise<void> {
|
||||
return recordSessionRevocation(effectContainer(ctx), session, { log: backendLog })
|
||||
}
|
||||
|
||||
/**
|
||||
* The account module's revocation job (the Postgres worker's REVOCATION_JOB) in this action: every
|
||||
* due row is claimed and delivered, a row older than 65 minutes lapses, finished rows are purged
|
||||
* after a day. A run that leaves rows waiting fails with `account_revocations_waiting`.
|
||||
*/
|
||||
export async function sweepRevocationsInBackend(ctx: EffectActionCtx): Promise<void> {
|
||||
const job = registry.jobs[REVOCATION_JOB]
|
||||
if (!job) throw internal("the account module declares no revocation job")
|
||||
await job.def.handler(null, {
|
||||
container: effectContainer(ctx),
|
||||
correlationId: globalThis.crypto.randomUUID(),
|
||||
attempt: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The container of one effect action: the module ports from the deployment variables, and the
|
||||
* account module's store, registry and the caller's credential and device session (none for a
|
||||
* scheduled action).
|
||||
*/
|
||||
export function effectContainer(
|
||||
ctx: EffectActionCtx,
|
||||
credential: AccountCredential = { interactive: false },
|
||||
requestSession: string | null = null,
|
||||
): Container {
|
||||
const container = new Container()
|
||||
// The device session a derived service session (a Support widget session) is minted for, so that
|
||||
// device's sign-out ends it (the revocation outbox asks by it).
|
||||
container.value(REQUEST_SESSION, requestSession)
|
||||
container.value(NESTED, nestedProcedures(ctx))
|
||||
container.value(ACCOUNT_REGISTRY, registry)
|
||||
container.value(ACCOUNT_STORE, convexAccountStore(ctx))
|
||||
container.value(REVOCATION_STORE, convexRevocationStore(ctx))
|
||||
container.value(ACCOUNT_CREDENTIAL, credential)
|
||||
// kit:convex-better-auth:begin -- a deletion ends the person's Better Auth account (the deploy job
|
||||
// installs the Better Auth deployment's URL, admin key and namespace in this backend).
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "convex-better-auth") {
|
||||
container.register(ACCOUNT_IDENTITY_PROVIDER, () => betterAuthAccountEndFromEnv(process.env))
|
||||
}
|
||||
// kit:convex-better-auth:end
|
||||
// kit:guides:begin
|
||||
container.value(GUIDES_REGISTRY, registry)
|
||||
container.value(GUIDES_PLATFORM, guidesPlatform(platformEnv()))
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
container.value(SUPPORT_PLATFORM, supportPlatform({ ...platformEnv(), channel: process.env.CONUSAI_SUPPORT_CHANNEL }))
|
||||
// kit:support:end
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one effect procedure in this action (the generated wrapper's ctx.run). Only an effect of a
|
||||
* module listed in CONVEX_ACTION_EFFECT_MODULES runs; anything else fails closed.
|
||||
*/
|
||||
export async function callEffectProcedure(
|
||||
ctx: EffectActionCtx,
|
||||
name: string,
|
||||
input: unknown,
|
||||
options?: CallOptions,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const entry = actionRegistry.procedures.get(name)
|
||||
if (entry?.def.kind !== "effect" || !CONVEX_ACTION_EFFECT_MODULES.has(entry.module)) {
|
||||
throw internal(`${name} is not an effect procedure this profile runs as an action`)
|
||||
}
|
||||
const callContext = await callContextOf(ctx.auth)
|
||||
return await createCaller(
|
||||
{
|
||||
registry: actionRegistry,
|
||||
container: effectContainer(ctx, callContext.accountCredential, callContext.requestSession),
|
||||
tenancy: backendTenancy(ctx),
|
||||
},
|
||||
callContext,
|
||||
).callByName(name, input, options)
|
||||
} catch (error) {
|
||||
// An unexpected failure stays opaque to the caller but not to the operator: the backend log
|
||||
// names it (an AppError is the kernel's own answer and needs no log line).
|
||||
if (!(error instanceof AppError)) console.error(`effect ${name} failed`, error)
|
||||
// The same problem contract as every other Convex function (lib/runtime.ts).
|
||||
throw new ConvexError(toProblem(error) as unknown as Value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tenant function builders the generated wrappers are built with (spec §5.4: convex-helpers
|
||||
// custom functions + the repository predicate are the tenant wall). Each adds `ctx.run`, which
|
||||
// runs a procedure of the registry inside this function's transaction (lib/runtime.ts) or, in an
|
||||
// action, an effect procedure through the action composition (lib/effects.ts).
|
||||
|
||||
import { makeTenantFunctions } from "@conusai-kit/data-convex/runtime"
|
||||
import { action, mutation, query } from "../_generated/server"
|
||||
import { callEffectProcedure, type EffectActionCtx } from "./effects"
|
||||
import { callProcedure } from "./runtime"
|
||||
|
||||
export const { tenantQuery, tenantMutation, tenantAction } = makeTenantFunctions(
|
||||
{ query, mutation, action },
|
||||
(ctx) => (name, input, options) =>
|
||||
"db" in ctx
|
||||
? callProcedure(ctx, name, input, options)
|
||||
: callEffectProcedure(ctx as unknown as EffectActionCtx, name, input, options),
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex composition for this app (spec §5.4): one registry, the data source bound to the
|
||||
// calling function's own ctx.db (the whole invocation is one Convex transaction), the tenancy
|
||||
// hooks every profile shares, and the caller every procedure goes through. `callProcedure` is
|
||||
// what the generated wrappers' ctx.run invokes; the jobs dispatcher and the outbox relay build
|
||||
// on the same pieces.
|
||||
|
||||
import { authModule } from "@conusai-kit/auth/module"
|
||||
import {
|
||||
buildRegistry,
|
||||
type CallOptions,
|
||||
Container,
|
||||
createCaller,
|
||||
type ModuleManifest,
|
||||
type Registry,
|
||||
toProblem,
|
||||
unauthenticated,
|
||||
} from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, entitiesOf, JOB_QUEUE, OBJECT_STORE, type ObjectStore, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import type { ConvexWriter } from "@conusai-kit/data-convex"
|
||||
import { createConvexDataSource, createConvexJobQueue } from "@conusai-kit/data-convex"
|
||||
import { actorFromIdentity, executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import { FILES_REGISTRY, filesModule } from "@conusai-kit/files"
|
||||
import { guidesModule } from "@conusai-kit/guides" // kit:guides
|
||||
import { PROPOSALS_REGISTRY, proposalsModule } from "@conusai-kit/proposals"
|
||||
import { supportModule } from "@conusai-kit/support" // kit:support
|
||||
import { createTenancyHooks, TENANCY, tenancyModule } from "@conusai-kit/tenancy"
|
||||
import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx, UserIdentity } from "convex/server"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { ConvexError, type Value } from "convex/values"
|
||||
import {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_REGISTRY,
|
||||
type AccountCredential,
|
||||
accountModule,
|
||||
} from "../../modules/dist/account/index.js"
|
||||
import { convexBrandingModule } from "../../modules/dist/branding/module.convex.js"
|
||||
import { convexDocumentsModule } from "../../modules/dist/documents/module.convex.js"
|
||||
import { NOTES_FLAGS } from "../../modules/dist/notes/core/ports.js"
|
||||
import { notesModule } from "../../modules/dist/notes/module.js"
|
||||
import { CONVEX_EXCLUDED_MODULES, convexModules } from "./convexModules.mjs"
|
||||
|
||||
// One import per deployable module: the bundle may not contain the excluded ones AT ALL (their
|
||||
// handlers import Node builtins, and queries/mutations never run under "use node"), so this
|
||||
// cannot be `appModules.filter(...)` -- importing the module list would import them. The
|
||||
// exclusion map is the shared record; test/convex-profile.test.ts proves this list is exactly
|
||||
// convexModules(appModules), so the generator and the runtime never disagree.
|
||||
export const deployableModules: readonly ModuleManifest[] = convexModules([
|
||||
tenancyModule,
|
||||
authModule,
|
||||
filesModule,
|
||||
proposalsModule,
|
||||
guidesModule, // kit:guides -- its effect procedures run as Convex actions (lib/effects.ts)
|
||||
supportModule, // kit:support -- its effect procedures run as Convex actions (lib/effects.ts)
|
||||
convexBrandingModule,
|
||||
convexDocumentsModule,
|
||||
notesModule,
|
||||
accountModule, // export and deletion run as Convex actions over internal store functions (convex/account.ts)
|
||||
])
|
||||
for (const module of deployableModules) {
|
||||
if (CONVEX_EXCLUDED_MODULES.has(module.id)) {
|
||||
throw new Error(
|
||||
`convex profile: ${module.id} is excluded (${CONVEX_EXCLUDED_MODULES.get(module.id)}) but still imported`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one registry, built from the manifests of the modules this profile deploys. */
|
||||
export const registry: Registry = buildRegistry([...deployableModules])
|
||||
|
||||
/** The Convex data source over every convex-bound entity of the registry. */
|
||||
export const convexEntities = entitiesOf(registry, "convex")
|
||||
|
||||
/** The expected server contract exposed by system.ready and checked by the web readiness probe. */
|
||||
export const convexCapabilities = {
|
||||
modules: deployableModules.map(({ id }) => id).sort(),
|
||||
procedures: [...registry.procedures.keys()].sort(),
|
||||
actions: [
|
||||
"documentsActions:process",
|
||||
"filesActions:begin",
|
||||
"filesActions:complete",
|
||||
"filesActions:remove",
|
||||
"filesActions:cleanupDelete",
|
||||
"filesActions:recoverUpload",
|
||||
"filesActions:readiness",
|
||||
],
|
||||
tables: convexEntities.map(({ name }) => name).sort(),
|
||||
schema: convexEntities
|
||||
.map((entity) => ({
|
||||
name: entity.name,
|
||||
table: entity.table,
|
||||
tenancy: entity.tenancy,
|
||||
columns: [...entity.columns].sort(),
|
||||
unique: entity.unique.map((fields) => [...fields]),
|
||||
indexes: entity.indexes.map((fields) => [...fields]),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
}
|
||||
|
||||
/**
|
||||
* The only identity accepted by public Convex functions is one the Next BFF attaches while it
|
||||
* holds the linked service's admin key. auth.config.ts intentionally configures no external JWT
|
||||
* provider for this issuer: a browser or a caller who knows a user id cannot forge this identity.
|
||||
*/
|
||||
export const CONVEX_BFF_ISSUER = "https://conusai.internal/bff"
|
||||
|
||||
// `kit-files.filesDeps` resolves this port eagerly even for notes/file metadata operations that
|
||||
// never touch bytes. External object-store calls cannot run in a Convex mutation; the file upload
|
||||
// and delete procedures therefore use the explicit action/mutation workflows in filesActions.ts
|
||||
// and files.ts, rather than running through this generic procedure dispatcher.
|
||||
export const unavailableObjectStore = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, operation) => async () => {
|
||||
throw new Error(`Convex object-store operation ${String(operation)} requires its action workflow`)
|
||||
},
|
||||
},
|
||||
) as ObjectStore
|
||||
|
||||
interface FunctionCtx<DataModel extends GenericDataModel> {
|
||||
db?: GenericQueryCtx<DataModel>["db"] | GenericMutationCtx<DataModel>["db"]
|
||||
auth: { getUserIdentity(): Promise<UserIdentity | null> }
|
||||
scheduler?: GenericMutationCtx<DataModel>["scheduler"]
|
||||
}
|
||||
|
||||
export interface CallContext {
|
||||
actor?: ReturnType<typeof actorFromIdentity>
|
||||
pinnedOrgId?: string
|
||||
/** What the account module asks of the caller's credential (ACCOUNT_CREDENTIAL), from the BFF's identity. */
|
||||
accountCredential: AccountCredential
|
||||
/**
|
||||
* The device's app session (kit-auth `sessionRefKey`) a derived service session is minted for
|
||||
* (REQUEST_SESSION), from the BFF's identity: that device's sign-out then ends it.
|
||||
*/
|
||||
requestSession: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential claims the BFF attaches to the identity (apps/web/src/server/convex-caller.ts):
|
||||
* whether a person signed in to this app is behind the call, the kit session row it is bound to, or
|
||||
* the sign-in time an external provider reported for a session with no row. Only an identity the
|
||||
* admin key holder attached reaches here, so these are the BFF's facts, never a browser's.
|
||||
*/
|
||||
export function accountCredentialOf(identity: UserIdentity | null): AccountCredential {
|
||||
const claims = (identity ?? {}) as UserIdentity & {
|
||||
conusaiCredential?: unknown
|
||||
conusaiSessionId?: unknown
|
||||
conusaiSignedInAt?: unknown
|
||||
}
|
||||
if (claims.conusaiCredential !== "interactive") return { interactive: false }
|
||||
return {
|
||||
interactive: true,
|
||||
...(typeof claims.conusaiSessionId === "string" ? { sessionId: claims.conusaiSessionId } : {}),
|
||||
...(typeof claims.conusaiSignedInAt === "string" ? { signedInAt: claims.conusaiSignedInAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The device session the BFF names for a signed-in person's call (`conusaiIdentitySession`, the
|
||||
* kit-auth `sessionRefKey` of the request's app session), or null. Like the credential claims, only
|
||||
* an identity the admin key holder attached reaches here.
|
||||
*/
|
||||
export function requestSessionOf(identity: UserIdentity | null): string | null {
|
||||
const claim = (identity as (UserIdentity & { conusaiIdentitySession?: unknown }) | null)?.conusaiIdentitySession
|
||||
return typeof claim === "string" && claim.length > 0 ? claim : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The actor and the organization pin of a call, from the verified identity: only a BFF identity
|
||||
* attached with the self-hosted admin key is accepted (an absent identity is an anonymous call).
|
||||
*/
|
||||
export async function callContextOf(auth: FunctionCtx<GenericDataModel>["auth"]): Promise<CallContext> {
|
||||
const identity = await auth.getUserIdentity()
|
||||
if (identity && identity.issuer !== CONVEX_BFF_ISSUER) {
|
||||
throw unauthenticated("this Convex function accepts calls only from the authenticated app BFF")
|
||||
}
|
||||
const callContext: CallContext = {
|
||||
accountCredential: accountCredentialOf(identity),
|
||||
requestSession: requestSessionOf(identity),
|
||||
}
|
||||
if (identity) {
|
||||
callContext.actor = actorFromIdentity(identity)
|
||||
const pinnedOrgId = (identity as UserIdentity & { conusaiOrgId?: string }).conusaiOrgId
|
||||
if (typeof pinnedOrgId === "string") callContext.pinnedOrgId = pinnedOrgId
|
||||
}
|
||||
return callContext
|
||||
}
|
||||
|
||||
/** The tenancy hooks every procedure uses, over a function's own ctx.db. */
|
||||
export function tenancyOver<DataModel extends GenericDataModel>(
|
||||
db: NonNullable<FunctionCtx<DataModel>["db"]>,
|
||||
): ReturnType<typeof createTenancyHooks> {
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor({ db }) })
|
||||
return createTenancyHooks({ data, registry })
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one procedure inside this function's transaction: the actor from the verified identity
|
||||
* (a BFF identity attached with the self-hosted admin key), the scope resolved by the
|
||||
* tenancy hooks from the input's slugs, the permission checked, the handler run with the
|
||||
* capability container of its kind -- the same path the HTTP mount takes on the other profiles.
|
||||
*/
|
||||
export async function callProcedure<DataModel extends GenericDataModel>(
|
||||
ctx: FunctionCtx<DataModel>,
|
||||
name: string,
|
||||
input: unknown,
|
||||
options?: CallOptions,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.db) {
|
||||
throw new Error(
|
||||
"Convex effect procedures run through lib/effects.ts (callEffectProcedure); actions cannot share a database transaction",
|
||||
)
|
||||
}
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor({ db: ctx.db }) })
|
||||
const callContext = await callContextOf(ctx.auth)
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
container.value(OBJECT_STORE, unavailableObjectStore)
|
||||
container.value(FILES_REGISTRY, registry)
|
||||
container.value(PROPOSALS_REGISTRY, registry)
|
||||
container.value(ACCOUNT_REGISTRY, registry)
|
||||
container.value(ACCOUNT_CREDENTIAL, callContext.accountCredential)
|
||||
// File actions use the same transactional row model as notes, so the profile can expose the
|
||||
// module's default attachment flag after its full upload round-trip gate passes.
|
||||
container.value(NOTES_FLAGS, { enabled: () => true })
|
||||
// Only mutations have a scheduler. The queue writes its job row and schedules the dispatcher
|
||||
// in this same ctx.db transaction, so rollback cannot leave either half behind.
|
||||
if (ctx.scheduler) {
|
||||
const jobDb = ctx.db as unknown as ConvexWriter
|
||||
container.value(
|
||||
JOB_QUEUE,
|
||||
createConvexJobQueue({
|
||||
db: jobDb,
|
||||
schedule: ({ delayMs, jobId, inline }) =>
|
||||
ctx.scheduler
|
||||
?.runAfter(delayMs, makeFunctionReference<"mutation">("conusaiJobs:run"), {
|
||||
jobId,
|
||||
...(inline ? { inline } : {}),
|
||||
})
|
||||
.then(() => undefined) ?? Promise.reject(new Error("Convex scheduler is unavailable in this function")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
container.value(TENANCY, tenancy)
|
||||
try {
|
||||
return await createCaller({ registry, container, tenancy }, callContext).callByName(name, input, options)
|
||||
} catch (error) {
|
||||
// Convex serializes ConvexError.data across the HTTP boundary. Keep the kernel's problem
|
||||
// contract intact for the trusted BFF, while unknown backend failures stay opaque.
|
||||
throw new ConvexError(toProblem(error) as unknown as Value)
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The outbox relay (spec §5.4: outbox events are scheduled internal mutations). A write commits
|
||||
// its rows and its events in one transaction; this cron-driven pass delivers them to the
|
||||
// modules' subscribers, at least once. A suspended organization's events are deferred, not
|
||||
// failed (AT-D19), exactly as the pg-boss relay does on the postgres profile.
|
||||
|
||||
import { Container } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { DATA_SOURCE, JOB_QUEUE, OBJECT_STORE, SYSTEM_DATA, toInstant } from "@conusai-kit/data"
|
||||
import { type ConvexWriter, createConvexDataSource, createConvexJobQueue } from "@conusai-kit/data-convex"
|
||||
import { executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import { FILES_AUDIT, FILES_REGISTRY, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
|
||||
import { PROPOSALS_AUDIT, PROPOSALS_SCOPE } from "@conusai-kit/proposals"
|
||||
import { organizations } from "@conusai-kit/tenancy"
|
||||
import { Audit, defineAuditEvents } from "@conusai-sdk/convex-audit"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { components, internal } from "./_generated/api"
|
||||
import { internalMutation } from "./_generated/server"
|
||||
import { convexEntities, registry, unavailableObjectStore } from "./lib/runtime"
|
||||
|
||||
const auditEvents = defineAuditEvents(
|
||||
Object.fromEntries(Object.keys(registry.audit).map((action) => [action, { target: "organization" }])) as never,
|
||||
)
|
||||
const audit = new Audit(components.audit, {
|
||||
events: auditEvents,
|
||||
credentialsProvider: internal.auditCredentials.get,
|
||||
})
|
||||
const requiredEnv = (name: string): string => {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Convex deployment is missing ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
/** The next look at a deferred record: one minute doubling per deferral, capped at fifteen. */
|
||||
export function relayDeferralDelayMs(deferrals: number): number {
|
||||
return Math.min(900_000, 60_000 * 2 ** Math.min(deferrals, 30))
|
||||
}
|
||||
|
||||
export const relayPass = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) })
|
||||
const pending = await data.outbox.pending(50)
|
||||
let failureCount = 0
|
||||
for (const record of pending) {
|
||||
const org = await data.global(organizations).get(record.orgId)
|
||||
if (org?.status === "suspended") {
|
||||
// Held back without spending an attempt: a suspension is not a delivery failure.
|
||||
await data.outbox.defer(
|
||||
record.id,
|
||||
toInstant(Date.now() + relayDeferralDelayMs(record.deferrals)),
|
||||
"the organization is suspended",
|
||||
)
|
||||
continue
|
||||
}
|
||||
const entry = registry.events[record.name]
|
||||
let failed: string | undefined
|
||||
for (const subscription of entry?.subscribers ?? []) {
|
||||
const usageEvents: Array<Record<string, unknown>> = []
|
||||
try {
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
container.value(OBJECT_STORE, unavailableObjectStore)
|
||||
container.value(
|
||||
JOB_QUEUE,
|
||||
createConvexJobQueue({
|
||||
db: ctx.db as unknown as ConvexWriter,
|
||||
schedule: ({ delayMs, jobId, inline }) =>
|
||||
ctx.scheduler
|
||||
.runAfter(delayMs, makeFunctionReference<"mutation">("conusaiJobs:run"), {
|
||||
jobId,
|
||||
...(inline ? { inline } : {}),
|
||||
})
|
||||
.then(() => undefined),
|
||||
}),
|
||||
)
|
||||
const deliveryScope = {
|
||||
level: "org",
|
||||
orgId: record.orgId,
|
||||
actor: systemActor("outbox.relay"),
|
||||
} as const
|
||||
container.value(FILES_SCOPE, deliveryScope)
|
||||
container.value(PROPOSALS_SCOPE, deliveryScope)
|
||||
container.value(FILES_REGISTRY, registry)
|
||||
container.value(FILES_AUDIT, {
|
||||
log: async (action: string, fields: Record<string, unknown>) => {
|
||||
await audit.log(ctx, action as never, fields as never)
|
||||
},
|
||||
})
|
||||
container.value(PROPOSALS_AUDIT, {
|
||||
log: async (action: string, fields: Record<string, unknown>) => {
|
||||
await audit.log(ctx, action as never, fields as never)
|
||||
},
|
||||
})
|
||||
container.value(FILES_USAGE, {
|
||||
track: (event: Record<string, unknown>) => {
|
||||
usageEvents.push(event)
|
||||
},
|
||||
flush: async () => {
|
||||
for (const event of usageEvents.splice(0)) {
|
||||
await ctx.scheduler.runAfter(0, makeFunctionReference<"action">("usage:deliver"), {
|
||||
attempt: 0,
|
||||
event,
|
||||
orgSlug: requiredEnv("CONUSAI_ORG_SLUG"),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
await subscription.handler(
|
||||
{ name: record.name, payload: record.payload },
|
||||
{ container, correlationId: record.id },
|
||||
)
|
||||
} catch (error) {
|
||||
failed = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
if (failed === undefined) await data.outbox.markPublished([record.id])
|
||||
else {
|
||||
failureCount += 1
|
||||
await data.outbox.markFailed(record.id, failed)
|
||||
}
|
||||
}
|
||||
return { delivered: pending.length, failed: failureCount }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand: regenerate.
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
"account_deletion": defineTable(v.object({ "abandoned": v.array(v.string()), "attempts": v.number(), "completedAt": v.union(v.string(), v.null()), "createdAt": v.string(), "erased": v.array(v.string()), "id": v.string(), "lastFailure": v.union(v.string(), v.null()), "pending": v.array(v.string()), "provider": v.union(v.string(), v.null()), "requestedAt": v.string(), "state": v.union(v.literal("erasing"), v.literal("completed")), "steps": v.union(v.array(v.union(v.literal("credentials"), v.literal("service-sessions"), v.literal("provider-account"), v.literal("identity"))), v.null()), "updatedAt": v.string(), "userId": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_unique_userId", ["userId"])
|
||||
.index("by_state", ["state"]),
|
||||
"account_session_revocation": defineTable(v.object({ "attempts": v.number(), "claimedUntil": v.union(v.string(), v.null()), "completedAt": v.union(v.string(), v.null()), "createdAt": v.string(), "delivered": v.array(v.string()), "id": v.string(), "lastFailure": v.union(v.string(), v.null()), "nextAttemptAt": v.string(), "pending": v.array(v.string()), "requestedAt": v.string(), "session": v.string(), "state": v.union(v.literal("pending"), v.literal("delivered"), v.literal("lapsed")), "updatedAt": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_unique_session", ["session"])
|
||||
.index("by_state_nextAttemptAt", ["state", "nextAttemptAt"]),
|
||||
"app_user_identity": defineTable(v.object({ "createdAt": v.string(), "id": v.string(), "issuer": v.string(), "migrationId": v.union(v.string(), v.null()), "subject": v.string(), "updatedAt": v.string(), "userId": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_unique_issuer_subject", ["issuer", "subject"])
|
||||
.index("by_userId", ["userId"]),
|
||||
"auth_api_keys": defineTable(v.object({ "createdAt": v.string(), "expiresAt": v.union(v.string(), v.null()), "id": v.string(), "name": v.string(), "orgId": v.string(), "prefix": v.string(), "revokedAt": v.union(v.string(), v.null()), "tokenHash": v.string(), "updatedAt": v.string(), "userId": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"])
|
||||
.index("by_unique_tokenHash", ["orgId", "tokenHash"])
|
||||
.index("by_userId", ["orgId", "userId"])
|
||||
.index("by_prefix", ["orgId", "prefix"]),
|
||||
"auth_sessions": defineTable(v.object({ "absoluteExpiresAt": v.string(), "accessTokenExpiresAt": v.string(), "createdAt": v.string(), "handleHash": v.string(), "id": v.string(), "issuer": v.string(), "lastSeenAt": v.string(), "revokedAt": v.union(v.string(), v.null()), "subject": v.string(), "tokens": v.string(), "updatedAt": v.string(), "userId": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_unique_handleHash", ["handleHash"])
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_absoluteExpiresAt", ["absoluteExpiresAt"]),
|
||||
"conusai_idempotency": defineTable(v.object({ "createdAt": v.string(), "key": v.string(), "response": v.string() }))
|
||||
.index("by_key", ["key"]),
|
||||
"conusai_jobs": defineTable(v.object({ "attempt": v.number(), "correlationId": v.string(), "createdAt": v.string(), "dedup": v.optional(v.string()), "id": v.string(), "job": v.string(), "lastError": v.optional(v.string()), "leaseUntil": v.optional(v.string()), "onBehalfOf": v.union(v.string(), v.null()), "orgId": v.string(), "payload": v.any(), "projectId": v.optional(v.string()), "retryLimit": v.number(), "runAt": v.string(), "status": v.string(), "workspaceId": v.optional(v.string()) }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_dedup", ["dedup"])
|
||||
.index("by_status_run", ["status", "runAt"]),
|
||||
"conusai_outbox": defineTable(v.object({ "attempts": v.number(), "createdAt": v.string(), "deferrals": v.number(), "deferredSince": v.optional(v.string()), "deferredUntil": v.optional(v.string()), "id": v.string(), "lastError": v.optional(v.string()), "name": v.string(), "orgId": v.string(), "payload": v.any(), "publishedAt": v.optional(v.string()) }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"]),
|
||||
"documents_document": defineTable(v.object({ "createdAt": v.string(), "failureCode": v.union(v.union(v.literal("file_missing"), v.literal("file_changed"), v.literal("ocr_failed"), v.literal("extraction_refused"), v.literal("extraction_truncated"), v.literal("extraction_invalid")), v.null()), "fileId": v.string(), "filename": v.string(), "finishedAt": v.union(v.string(), v.null()), "id": v.string(), "ocrJobId": v.union(v.number(), v.null()), "orgId": v.string(), "pageCount": v.union(v.number(), v.null()), "projectId": v.string(), "proposedCount": v.union(v.number(), v.null()), "skippedPages": v.union(v.array(v.object({ "page": v.number(), "pageClass": v.union(v.literal("text"), v.literal("table"), v.literal("drawing"), v.literal("scan"), v.literal("empty")) })), v.null()), "status": v.union(v.literal("processing"), v.literal("extracted"), v.literal("failed")), "updatedAt": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_project", ["orgId", "workspaceId", "projectId"])
|
||||
.index("by_unique_fileId", ["orgId", "workspaceId", "projectId", "fileId"])
|
||||
.index("by_status", ["orgId", "workspaceId", "projectId", "status"]),
|
||||
"documents_line": defineTable(v.object({ "approvedBy": v.string(), "createdAt": v.string(), "documentId": v.string(), "edited": v.boolean(), "id": v.string(), "label": v.string(), "orgId": v.string(), "page": v.number(), "position": v.number(), "projectId": v.string(), "proposalId": v.string(), "quote": v.string(), "updatedAt": v.string(), "value": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_project", ["orgId", "workspaceId", "projectId"])
|
||||
.index("by_unique_proposalId", ["orgId", "workspaceId", "projectId", "proposalId"])
|
||||
.index("by_documentId", ["orgId", "workspaceId", "projectId", "documentId"]),
|
||||
"extensions": defineTable(v.object({ "createdAt": v.string(), "id": v.string(), "namespace": v.string(), "orgId": v.string(), "payload": v.record(v.string(), v.any()), "payloadVersion": v.number(), "subjectId": v.string(), "subjectType": v.string(), "updatedAt": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"])
|
||||
.index("by_unique_subjectType_subjectId_namespace", ["orgId", "subjectType", "subjectId", "namespace"])
|
||||
.index("by_namespace", ["orgId", "namespace"]),
|
||||
"files_file": defineTable(v.object({ "contentType": v.string(), "createdAt": v.string(), "declaredSize": v.union(v.number(), v.null()), "deletingAt": v.union(v.string(), v.null()), "filename": v.string(), "id": v.string(), "orgId": v.string(), "partSize": v.union(v.number(), v.null()), "projectId": v.string(), "sha256": v.union(v.string(), v.null()), "size": v.union(v.number(), v.null()), "status": v.union(v.literal("pending"), v.literal("stored"), v.literal("deleting")), "storageKey": v.string(), "storedAt": v.union(v.string(), v.null()), "updatedAt": v.string(), "uploadId": v.union(v.string(), v.null()), "uploadedBy": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_project", ["orgId", "workspaceId", "projectId"])
|
||||
.index("by_unique_storageKey", ["orgId", "workspaceId", "projectId", "storageKey"])
|
||||
.index("by_status", ["orgId", "workspaceId", "projectId", "status"])
|
||||
.index("by_sha256", ["orgId", "workspaceId", "projectId", "sha256"]),
|
||||
"invitations": defineTable(v.object({ "acceptedAt": v.union(v.string(), v.null()), "createdAt": v.string(), "email": v.string(), "expiresAt": v.string(), "id": v.string(), "invitedBy": v.string(), "orgId": v.string(), "role": v.union(v.literal("viewer"), v.literal("member"), v.literal("admin"), v.literal("owner")), "tokenHash": v.string(), "updatedAt": v.string(), "version": v.number(), "workspaceId": v.union(v.string(), v.null()), "workspaceRole": v.union(v.union(v.literal("viewer"), v.literal("editor"), v.literal("admin")), v.null()) }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"])
|
||||
.index("by_unique_tokenHash", ["orgId", "tokenHash"])
|
||||
.index("by_email", ["orgId", "email"])
|
||||
.index("by_acceptedAt", ["orgId", "acceptedAt"]),
|
||||
"notes_attachment": defineTable(v.object({ "attachedBy": v.string(), "createdAt": v.string(), "fileId": v.string(), "id": v.string(), "noteId": v.string(), "orgId": v.string(), "projectId": v.string(), "updatedAt": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_project", ["orgId", "workspaceId", "projectId"])
|
||||
.index("by_unique_noteId_fileId", ["orgId", "workspaceId", "projectId", "noteId", "fileId"])
|
||||
.index("by_noteId", ["orgId", "workspaceId", "projectId", "noteId"])
|
||||
.index("by_fileId", ["orgId", "workspaceId", "projectId", "fileId"]),
|
||||
"notes_note": defineTable(v.object({ "body": v.string(), "createdAt": v.string(), "createdBy": v.string(), "id": v.string(), "orgId": v.string(), "projectId": v.string(), "title": v.string(), "updatedAt": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_project", ["orgId", "workspaceId", "projectId"])
|
||||
.index("by_createdBy", ["orgId", "workspaceId", "projectId", "createdBy"]),
|
||||
"org_memberships": defineTable(v.object({ "createdAt": v.string(), "id": v.string(), "orgId": v.string(), "role": v.union(v.literal("viewer"), v.literal("member"), v.literal("admin"), v.literal("owner")), "updatedAt": v.string(), "userId": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"])
|
||||
.index("by_unique_userId", ["orgId", "userId"])
|
||||
.index("by_role", ["orgId", "role"]),
|
||||
"organizations": defineTable(v.object({ "branding": v.union(v.object({ "accent": v.optional(v.string()), "displayName": v.optional(v.string()), "logoDark": v.optional(v.string()), "logoLight": v.optional(v.string()), "primary": v.optional(v.string()) }), v.null()), "createdAt": v.string(), "id": v.string(), "name": v.string(), "settings": v.record(v.string(), v.any()), "slug": v.string(), "status": v.union(v.literal("active"), v.literal("suspended")), "updatedAt": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_unique_slug", ["slug"])
|
||||
.index("by_status", ["status"]),
|
||||
"projects": defineTable(v.object({ "archivedAt": v.union(v.string(), v.null()), "createdAt": v.string(), "id": v.string(), "moduleKey": v.string(), "name": v.string(), "orgId": v.string(), "slug": v.string(), "updatedAt": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_workspace", ["orgId", "workspaceId"])
|
||||
.index("by_unique_slug", ["orgId", "workspaceId", "slug"])
|
||||
.index("by_archivedAt", ["orgId", "workspaceId", "archivedAt"]),
|
||||
"proposals": defineTable(v.object({ "change": v.record(v.string(), v.any()), "createdAt": v.string(), "createdBy": v.string(), "decidedAt": v.union(v.string(), v.null()), "decidedBy": v.union(v.string(), v.null()), "editedAt": v.union(v.string(), v.null()), "editedBy": v.union(v.string(), v.null()), "evidence": v.array(v.object({ "blockId": v.optional(v.string()), "box": v.optional(v.object({ "height": v.number(), "width": v.number(), "x": v.number(), "y": v.number() })), "fileId": v.string(), "page": v.optional(v.number()), "quote": v.optional(v.string()) })), "id": v.string(), "model": v.string(), "orgId": v.string(), "projectId": v.string(), "promptVersion": v.string(), "reason": v.union(v.string(), v.null()), "status": v.union(v.literal("pending"), v.literal("approved"), v.literal("rejected")), "targetKey": v.string(), "targetRecordId": v.union(v.string(), v.null()), "updatedAt": v.string(), "version": v.number(), "workspaceId": v.string(), "writtenRecordId": v.union(v.string(), v.null()) }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org_project", ["orgId", "workspaceId", "projectId"])
|
||||
.index("by_status", ["orgId", "workspaceId", "projectId", "status"])
|
||||
.index("by_targetKey_targetRecordId", ["orgId", "workspaceId", "projectId", "targetKey", "targetRecordId"]),
|
||||
"users": defineTable(v.object({ "createdAt": v.string(), "displayName": v.string(), "email": v.string(), "id": v.string(), "issuer": v.string(), "locale": v.string(), "subject": v.string(), "updatedAt": v.string(), "version": v.number() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_unique_issuer_subject", ["issuer", "subject"])
|
||||
.index("by_email", ["email"]),
|
||||
"workspace_memberships": defineTable(v.object({ "createdAt": v.string(), "id": v.string(), "orgId": v.string(), "role": v.union(v.literal("viewer"), v.literal("editor"), v.literal("admin")), "updatedAt": v.string(), "userId": v.string(), "version": v.number(), "workspaceId": v.string() }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"])
|
||||
.index("by_unique_workspaceId_userId", ["orgId", "workspaceId", "userId"])
|
||||
.index("by_userId", ["orgId", "userId"]),
|
||||
"workspaces": defineTable(v.object({ "createdAt": v.string(), "id": v.string(), "isDefault": v.boolean(), "name": v.string(), "orgId": v.string(), "slug": v.string(), "updatedAt": v.string(), "version": v.number(), "visibility": v.union(v.literal("org"), v.literal("restricted")) }))
|
||||
.index("by_app_id", ["id"])
|
||||
.index("by_org", ["orgId"])
|
||||
.index("by_unique_slug", ["orgId", "slug"]),
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Private RPCs used only by the Next BFF's server-side ConvexHttpClient. They expose the narrow
|
||||
// global reads/writes kit-auth needs to maintain its sealed OIDC sessions and user rows. Domain
|
||||
// procedures never use this path: each of those executes through one generated public
|
||||
// query/mutation and therefore one real Convex transaction.
|
||||
|
||||
import { apiKeys, sessions } from "@conusai-kit/auth/entities"
|
||||
import type { Id } from "@conusai-kit/core"
|
||||
import { createConvexDataSource } from "@conusai-kit/data-convex"
|
||||
import { executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import {
|
||||
type AppIdentityClaims,
|
||||
type AppUserClaims,
|
||||
appUserIdentities,
|
||||
resolveAppUser,
|
||||
users,
|
||||
} from "@conusai-kit/tenancy"
|
||||
import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx } from "convex/server"
|
||||
import { v } from "convex/values"
|
||||
import { internalMutation, internalQuery } from "./_generated/server"
|
||||
import { convexCapabilities } from "./lib/runtime"
|
||||
|
||||
const entities = [users, appUserIdentities, sessions, apiKeys]
|
||||
type FunctionContext<DataModel extends GenericDataModel> = {
|
||||
db: GenericQueryCtx<DataModel>["db"] | GenericMutationCtx<DataModel>["db"]
|
||||
}
|
||||
const data = <DataModel extends GenericDataModel>(ctx: FunctionContext<DataModel>) =>
|
||||
createConvexDataSource({ entities, executor: executorFor(ctx) })
|
||||
|
||||
// One internal mutation is the whole first-login/backfill transaction. Separate BFF calls could
|
||||
// never provide the uniqueness and rollback guarantees of the resolver.
|
||||
export const appUserResolve = internalMutation({
|
||||
args: {
|
||||
claims: v.object({
|
||||
issuer: v.string(),
|
||||
subject: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
locale: v.optional(v.string()),
|
||||
}),
|
||||
create: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, { claims, create }) =>
|
||||
resolveAppUser(data(ctx), claims as AppUserClaims | AppIdentityClaims, { create }),
|
||||
})
|
||||
|
||||
export const sessionById = internalQuery({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, { id }) =>
|
||||
data(ctx)
|
||||
.global(sessions)
|
||||
.get(id as Id),
|
||||
})
|
||||
|
||||
export const sessionByHandleHash = internalQuery({
|
||||
args: { handleHash: v.string() },
|
||||
handler: async (ctx, { handleHash }) => {
|
||||
const page = await data(ctx)
|
||||
.global(sessions)
|
||||
.list({
|
||||
where: [{ field: "handleHash", op: "eq", value: handleHash }],
|
||||
limit: 1,
|
||||
})
|
||||
return page.items[0] ?? null
|
||||
},
|
||||
})
|
||||
|
||||
export const sessionInsert = internalMutation({
|
||||
args: { input: v.any() },
|
||||
handler: async (ctx, { input }) => data(ctx).global(sessions).insert(input),
|
||||
})
|
||||
|
||||
export const sessionUpdate = internalMutation({
|
||||
args: { id: v.string(), patch: v.any(), expectedVersion: v.number() },
|
||||
handler: async (ctx, { id, patch, expectedVersion }) =>
|
||||
data(ctx)
|
||||
.global(sessions)
|
||||
.update(id as Id, patch, expectedVersion),
|
||||
})
|
||||
|
||||
export const apiKeyByHash = internalQuery({
|
||||
args: { tokenHash: v.string() },
|
||||
handler: async (ctx, { tokenHash }) => {
|
||||
const source = data(ctx)
|
||||
const page = await source.acrossTenants(apiKeys).list({
|
||||
where: [{ field: "tokenHash", op: "eq", value: tokenHash }],
|
||||
limit: 1,
|
||||
})
|
||||
return page.items[0] ?? null
|
||||
},
|
||||
})
|
||||
|
||||
export const ready = internalQuery({
|
||||
args: {},
|
||||
handler: async () => ({ ready: true, ...convexCapabilities }),
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// External usage delivery runs as an action, never from a Convex mutation. The stable SDK
|
||||
// idempotency key makes scheduler retries safe if the ingest accepted a request but its response
|
||||
// was lost.
|
||||
|
||||
"use node"
|
||||
|
||||
import { UsageClient } from "@conusai-sdk/usage"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { v } from "convex/values"
|
||||
import { internalAction } from "./_generated/server"
|
||||
|
||||
const requiredEnv = (name: string): string => {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Convex deployment is missing ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const deliver = internalAction({
|
||||
args: {
|
||||
attempt: v.number(),
|
||||
event: v.any(),
|
||||
orgSlug: v.string(),
|
||||
},
|
||||
handler: async (ctx, { attempt, event, orgSlug }) => {
|
||||
const usage = new UsageClient({
|
||||
baseUrl: requiredEnv("CONUSAI_API_URL"),
|
||||
apiPrefix: "",
|
||||
apiKey: requiredEnv("CONUSAI_API_TOKEN"),
|
||||
orgSlug,
|
||||
maxRetries: 0,
|
||||
flushIntervalMs: 0,
|
||||
})
|
||||
usage.track(event)
|
||||
try {
|
||||
await usage.flush()
|
||||
} catch (error) {
|
||||
if (attempt >= 7) throw error
|
||||
const delayMs = Math.min(300_000, 1_000 * 2 ** attempt)
|
||||
await ctx.scheduler.runAfter(delayMs, makeFunctionReference<"action">("usage:deliver"), {
|
||||
attempt: attempt + 1,
|
||||
event,
|
||||
orgSlug,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user