Files

193 lines
6.7 KiB
TypeScript

// The documents: request an index (a web write that records the row and publishes the outbox
// event the worker's subscriber turns into the index job), request a removal the same way, and
// list a collection's documents with their progress states.
import { forbidden, notFound, validationFailed } from "@conusai-kit/core"
import { getFile } from "@conusai-kit/files"
import {
type DocumentView,
documentIndexRequested,
documentRemoveRequested,
documents,
INDEXABLE_CONTENT_TYPES,
MAX_INDEX_BYTES,
} from "../contract/index.js"
import { requireCollection } from "./collections.js"
import { type KnowledgeDeps, type KnowledgeScope, knowledgeEnabled } from "./ports.js"
const PAGE = 200
export const documentView = (row: {
id: string
collectionName: string
fileId: string
filename: string
status: DocumentView["status"]
error: string | null
indexedAt: string | null
requestedBy: string
}): DocumentView => ({
documentId: row.id,
collectionName: row.collectionName,
fileId: row.fileId,
filename: row.filename,
status: row.status,
error: row.error,
indexedAt: row.indexedAt,
requestedBy: row.requestedBy,
})
export async function listDocuments(
deps: KnowledgeDeps,
scope: KnowledgeScope,
collectionName: string,
): Promise<{ documents: DocumentView[] }> {
const found = []
let cursor: string | undefined
for (;;) {
const page = await deps.data
.forScope(scope)
.of(documents)
.list({
where: [{ field: "collectionName", op: "eq", value: collectionName }],
order: [{ field: "createdAt", direction: "desc" }],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
found.push(...page.items)
if (!page.hasMore) break
cursor = page.cursor
}
return { documents: found.map((row) => documentView(row)) }
}
/**
* Asks for a document's indexing. The write is three things in one transaction: the durable row
* (the UI's progress states and the job's idempotency key), the outbox event the worker's
* subscriber enqueues the index job from, and nothing else — chunking, embeddings and the vector
* store all happen in the worker, never in a request (AGENTS.md: long work is a job).
*
* Idempotent by (collection, file): asking again for a pair that is queued or indexing answers
* the same row and publishes nothing; asking for an indexed or failed pair re-queues the one row,
* so a retry can never duplicate a document.
*/
export async function requestIndex(
deps: KnowledgeDeps,
scope: KnowledgeScope,
input: { collectionName: string; fileId: string },
): Promise<DocumentView> {
// The tenant-wall reads come before the flag check: another tenant's ids answer NotFound, never
// a policy refusal, so ids do not leak existence behind a disabled flag.
await requireCollection(deps, scope, input.collectionName)
const file = await getFile(deps.files, scope, input.fileId)
if (!(INDEXABLE_CONTENT_TYPES as readonly string[]).includes(file.contentType)) {
throw validationFailed(
[{ path: "fileId", message: `${file.contentType} cannot be indexed; index a text file` }],
"this file type is not indexable",
)
}
if (file.size > MAX_INDEX_BYTES) {
throw validationFailed(
[{ path: "fileId", message: `the file is larger than ${MAX_INDEX_BYTES} bytes` }],
"the file is too large to index",
)
}
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
return deps.data.unitOfWork.run(scope, async (tx) => {
const existing = (
await tx.of(documents).list({
where: [
{ field: "collectionName", op: "eq", value: input.collectionName },
{ field: "fileId", op: "eq", value: input.fileId },
],
limit: 1,
})
).items[0]
if (existing && (existing.status === "queued" || existing.status === "indexing")) {
return documentView(existing)
}
const row =
existing !== undefined
? await tx
.of(documents)
.update(
existing.id,
{ status: "queued", error: null, indexedAt: null, requestedBy: scope.actor.userId },
existing.version,
)
: await tx.of(documents).insert({
collectionName: input.collectionName,
fileId: input.fileId,
filename: file.filename,
status: "queued",
error: null,
indexedAt: null,
requestedBy: scope.actor.userId,
})
await tx.publish(documentIndexRequested, {
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
documentId: row.id,
collectionName: input.collectionName,
fileId: input.fileId,
})
return documentView(row)
})
}
/** Asks for a document's removal: the row and the event in one transaction, the job in the worker. */
export async function requestRemove(
deps: KnowledgeDeps,
scope: KnowledgeScope,
input: { collectionName: string; fileId: string },
): Promise<DocumentView> {
const current = (
await deps.data
.forScope(scope)
.of(documents)
.list({
where: [
{ field: "collectionName", op: "eq", value: input.collectionName },
{ field: "fileId", op: "eq", value: input.fileId },
],
limit: 1,
})
).items[0]
// The tenant-wall read comes first, as in requestIndex: another tenant's ids answer NotFound.
if (!current) throw notFound("no such document")
if (current.status === "cancelled") return documentView(current)
if (!(await knowledgeEnabled(deps, scope))) throw forbidden("knowledge is switched off for this project")
return deps.data.unitOfWork.run(scope, async (tx) => {
const row = (
await tx.of(documents).list({
where: [
{ field: "collectionName", op: "eq", value: input.collectionName },
{ field: "fileId", op: "eq", value: input.fileId },
],
limit: 1,
})
).items[0]
if (!row) throw notFound("no such document")
if (row.status === "cancelled") return documentView(row)
const next = await tx.of(documents).update(row.id, { status: "cancelled", error: null }, row.version)
await tx.publish(documentRemoveRequested, {
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
documentId: row.id,
collectionName: input.collectionName,
fileId: input.fileId,
})
return documentView(next)
})
}
/** The document row a job resumes by; NotFound when another tenant's id was named. */
export async function documentById(deps: KnowledgeDeps, scope: KnowledgeScope, documentId: string) {
const row = await deps.data.forScope(scope).of(documents).get(documentId)
if (!row) throw notFound("no such document")
return row
}