// The dependencies every use case of the module takes, and how a proposal becomes a line a reviewer // sees. A line is not a row of this module until it is approved: until then it is a proposal of the // proposals module, and this file is the one place the two are read together. import type { TenantScope } from "@conusai-kit/core" import type { DataSource, Filter } from "@conusai-kit/data" import { type FilesDeps, sha256Hex } from "@conusai-kit/files" import { type ProposalRow, type ProposalsDeps, type ProposalView, proposals, viewOf } from "@conusai-kit/proposals" import { LINE_TARGET_KEY, type LineView, lineChangeSchema } from "../contract/index.js" /** A project scope: the only level a document can live at. */ export type DocumentScope = TenantScope & { workspaceId: string; projectId: string } export interface DocumentsDeps { readonly data: DataSource /** The files module's ports: a document is made from one of its files. */ readonly files: FilesDeps /** The proposals module's ports: an extracted line is one of its proposals. */ readonly proposals: ProposalsDeps } /** The page size used to read this module's proposals. */ const PAGE = 200 /** * Every proposal of this module's target matching the given record filter, any status, oldest * first. * * The proposals module's own `listProposals` filters by status alone, so the filter by target is * done here on its table, through the entity it exports: the `where` runs against the table's * `(targetKey, targetRecordId)` index instead of paging the project's whole queue into memory and * filtering it in JavaScript (one canonical copy of a line's state still — this adds a query, not * a table). When the kit's list grows a `targetRecordId` filter of its own, this becomes a call * of it again. */ async function proposalsWhere( deps: DocumentsDeps, scope: DocumentScope, target: { recordId: string } | { recordIds: readonly string[] } | null, ): Promise { const byRecord: Filter[] = target === null ? [] : "recordId" in target ? [{ field: "targetRecordId", op: "eq", value: target.recordId }] : [{ field: "targetRecordId", op: "in", values: target.recordIds }] const found: ProposalView[] = [] let cursor: string | undefined do { const page = await deps.proposals.data .forScope(scope) .of(proposals) .list({ where: [{ field: "targetKey", op: "eq", value: LINE_TARGET_KEY }, ...byRecord], order: [{ field: "createdAt", direction: "asc" }], limit: PAGE, ...(cursor === undefined ? {} : { cursor }), }) found.push(...page.items.map(viewOf)) cursor = page.hasMore ? page.cursor : undefined } while (cursor !== undefined) return found } /** Every proposal of this module's target in the project — or only one document's, when given. */ export function lineProposals(deps: DocumentsDeps, scope: DocumentScope, documentId?: string): Promise { return proposalsWhere(deps, scope, documentId === undefined ? null : { recordId: documentId }) } /** * This module's proposals of several documents at once, keyed by document id: the document list's * join, one indexed page chain instead of one pass over the queue per document. */ export async function lineProposalsByDocument( deps: DocumentsDeps, scope: DocumentScope, documentIds: readonly string[], ): Promise> { const byDocument = new Map() if (documentIds.length === 0) return byDocument for (const proposal of await proposalsWhere(deps, scope, { recordIds: documentIds })) { if (proposal.targetRecordId === null) continue const own = byDocument.get(proposal.targetRecordId) if (own) own.push(proposal) else byDocument.set(proposal.targetRecordId, [proposal]) } return byDocument } /** A stable key for the line at a position of a document: what makes an extraction safe to run twice. */ export const lineKeyOf = (documentId: string, page: number, position: number): Promise => sha256Hex(new TextEncoder().encode(`documents.line:${documentId}:${page}:${position}`)) /** The review screen's view of a proposal. */ export function lineViewOf(proposal: ProposalView): LineView { const change = lineChangeSchema.parse(proposal.change) return { lineId: proposal.proposalId, expectedVersion: proposal.version, status: proposal.status, label: change.label, value: change.value, page: change.page, quote: change.quote, box: proposal.evidence[0]?.box ?? null, confidence: change.confidence, verification: change.verification, note: change.note ?? null, blockId: proposal.evidence[0]?.blockId ?? null, edited: change.edited, model: proposal.model, promptVersion: proposal.promptVersion, decidedAt: proposal.decidedAt, } } /** The lines a reviewer should see, in reading order, not creation order: the lines of one extraction are * created within a millisecond of each other and their ids do not order them. */ export function visibleLines(proposals: readonly ProposalView[]): LineView[] { return [...proposals] .sort((a, b) => lineChangeSchema.parse(a.change).position - lineChangeSchema.parse(b.change).position) .map(lineViewOf) }