Files
template-app/modules/knowledge/adapters/jobs.ts
T

103 lines
3.9 KiB
TypeScript

// The knowledge jobs and the subscribers that enqueue them.
//
// A web request writes the document row and publishes the outbox event in one transaction; the
// module's own subscriber (running in the worker's outbox relay) turns the event into the job,
// with the (collection, file) pair as the singleton key — a redelivered event adds no second job.
// The tenant comes from the container, never from the payload (decision AT-F6).
import { type EventContext, internal, type JobDef, subscribe } from "@conusai-kit/core"
import { JOB_QUEUE } from "@conusai-kit/data"
import { z } from "zod"
import { documentIndexRequested, documentRemoveRequested, INDEX_JOB, REMOVE_JOB } from "../contract/index.js"
import { indexDocument, removeDocument } from "../core/index-job.js"
import { KNOWLEDGE_SCOPE, type KnowledgeScope } from "../core/ports.js"
import { knowledgeSearchDeps } from "./procedures.js"
export const documentPayload = z.object({
documentId: z.string(),
collectionName: z.string(),
fileId: z.string(),
})
export type DocumentJobPayload = z.output<typeof documentPayload>
export const indexJobKey = (collectionName: string, fileId: string): string =>
`knowledge.index.${collectionName}.${fileId}`
export const removeJobKey = (collectionName: string, fileId: string): string =>
`knowledge.remove.${collectionName}.${fileId}`
function projectScopeOf(ctx: EventContext, payload: { workspaceId: string; projectId: string }): KnowledgeScope {
const ambient = ctx.container.resolve(KNOWLEDGE_SCOPE)
if (ambient.level !== "project") throw internal("a knowledge event resolves to a project scope")
return { ...ambient, workspaceId: payload.workspaceId, projectId: payload.projectId }
}
export const indexJob: JobDef<DocumentJobPayload> = {
payload: documentPayload,
async handler(payload, ctx) {
const scope = ctx.container.resolve(KNOWLEDGE_SCOPE)
if (scope.level !== "project") throw internal(`a knowledge job runs in a project scope, not a ${scope.level} one`)
await indexDocument(knowledgeSearchDeps(ctx.container), scope, payload.documentId, {
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
})
},
}
export const removeJob: JobDef<DocumentJobPayload> = {
payload: documentPayload,
async handler(payload, ctx) {
const scope = ctx.container.resolve(KNOWLEDGE_SCOPE)
if (scope.level !== "project") throw internal(`a knowledge job runs in a project scope, not a ${scope.level} one`)
await removeDocument(knowledgeSearchDeps(ctx.container), scope, payload.documentId)
},
}
async function onDocumentIndexRequested(
event: {
name: string
payload: { workspaceId: string; projectId: string; documentId: string; collectionName: string; fileId: string }
},
ctx: EventContext,
): Promise<void> {
const scope = projectScopeOf(ctx, event.payload)
await ctx.container.resolve(JOB_QUEUE).enqueue(
scope,
INDEX_JOB,
{
documentId: event.payload.documentId,
collectionName: event.payload.collectionName,
fileId: event.payload.fileId,
},
{
key: indexJobKey(event.payload.collectionName, event.payload.fileId),
},
)
}
async function onDocumentRemoveRequested(
event: {
name: string
payload: { workspaceId: string; projectId: string; documentId: string; collectionName: string; fileId: string }
},
ctx: EventContext,
): Promise<void> {
const scope = projectScopeOf(ctx, event.payload)
await ctx.container.resolve(JOB_QUEUE).enqueue(
scope,
REMOVE_JOB,
{
documentId: event.payload.documentId,
collectionName: event.payload.collectionName,
fileId: event.payload.fileId,
},
{
key: removeJobKey(event.payload.collectionName, event.payload.fileId),
},
)
}
export const KNOWLEDGE_JOBS = { [INDEX_JOB]: indexJob, [REMOVE_JOB]: removeJob }
export const KNOWLEDGE_SUBSCRIPTIONS = [
subscribe(documentIndexRequested, onDocumentIndexRequested),
subscribe(documentRemoveRequested, onDocumentRemoveRequested),
]