133 lines
4.2 KiB
TypeScript
133 lines
4.2 KiB
TypeScript
// The collections: list them with their document counts, create one. A collection is a named
|
|
// bucket; its name is the slug every procedure takes, so a page never handles an id.
|
|
|
|
import { conflict, notFound, validationFailed } from "@conusai-kit/core"
|
|
import { type CollectionView, collections, documents } from "../contract/index.js"
|
|
import type { KnowledgeDeps, KnowledgeScope } from "./ports.js"
|
|
|
|
const PAGE = 200
|
|
|
|
export async function listCollections(
|
|
deps: KnowledgeDeps,
|
|
scope: KnowledgeScope,
|
|
): Promise<{ collections: CollectionView[] }> {
|
|
const found = []
|
|
let cursor: string | undefined
|
|
for (;;) {
|
|
const page = await deps.data
|
|
.forScope(scope)
|
|
.of(collections)
|
|
.list({
|
|
order: [{ field: "createdAt", direction: "asc" }],
|
|
limit: PAGE,
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
})
|
|
found.push(...page.items)
|
|
if (!page.hasMore) break
|
|
cursor = page.cursor
|
|
}
|
|
const views: CollectionView[] = []
|
|
for (const row of found) {
|
|
const counts = await documentCounts(deps, scope, row.name)
|
|
views.push({
|
|
name: row.name,
|
|
documentCount: counts.total,
|
|
indexedCount: counts.indexed,
|
|
createdBy: row.createdBy,
|
|
})
|
|
}
|
|
return { collections: views }
|
|
}
|
|
|
|
async function documentCounts(
|
|
deps: KnowledgeDeps,
|
|
scope: KnowledgeScope,
|
|
collectionName: string,
|
|
): Promise<{ total: number; indexed: number }> {
|
|
let total = 0
|
|
let indexed = 0
|
|
let cursor: string | undefined
|
|
for (;;) {
|
|
const page = await deps.data
|
|
.forScope(scope)
|
|
.of(documents)
|
|
.list({
|
|
where: [{ field: "collectionName", op: "eq", value: collectionName }],
|
|
limit: PAGE,
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
})
|
|
for (const row of page.items) {
|
|
total += 1
|
|
if (row.status === "indexed") indexed += 1
|
|
}
|
|
if (!page.hasMore) break
|
|
cursor = page.cursor
|
|
}
|
|
return { total, indexed }
|
|
}
|
|
|
|
/**
|
|
* Creates a collection, idempotently: a name that already exists answers with the existing
|
|
* collection and writes nothing, so a retried request never audits twice.
|
|
*/
|
|
export async function createCollection(
|
|
deps: KnowledgeDeps,
|
|
scope: KnowledgeScope,
|
|
name: string,
|
|
): Promise<CollectionView> {
|
|
const repos = deps.data.forScope(scope)
|
|
const existing = (await repos.of(collections).list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 }))
|
|
.items[0]
|
|
if (existing) {
|
|
const counts = await documentCounts(deps, scope, name)
|
|
return {
|
|
name: existing.name,
|
|
documentCount: counts.total,
|
|
indexedCount: counts.indexed,
|
|
createdBy: existing.createdBy,
|
|
}
|
|
}
|
|
try {
|
|
const row = await deps.data.unitOfWork.run(scope, async (tx) => {
|
|
const inserted = await tx.of(collections).insert({ name, createdBy: scope.actor.userId })
|
|
return inserted
|
|
})
|
|
return { name: row.name, documentCount: 0, indexedCount: 0, createdBy: row.createdBy }
|
|
} catch (error) {
|
|
// A concurrent create with the same name lost the race: answer the winner's row.
|
|
if ((error as { status?: number }).status === 409) {
|
|
const winner = (await repos.of(collections).list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 }))
|
|
.items[0]
|
|
if (winner) {
|
|
const counts = await documentCounts(deps, scope, name)
|
|
return {
|
|
name: winner.name,
|
|
documentCount: counts.total,
|
|
indexedCount: counts.indexed,
|
|
createdBy: winner.createdBy,
|
|
}
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** One collection by name, or NotFound: the resolver's own read is the membership check. */
|
|
export async function requireCollection(deps: KnowledgeDeps, scope: KnowledgeScope, name: string) {
|
|
const row = (
|
|
await deps.data
|
|
.forScope(scope)
|
|
.of(collections)
|
|
.list({ where: [{ field: "name", op: "eq", value: name }], limit: 1 })
|
|
).items[0]
|
|
if (!row) throw notFound("no such collection")
|
|
return row
|
|
}
|
|
|
|
export const unknownCollection = (name: string): never => {
|
|
throw validationFailed([{ path: "collectionName", message: "no such collection" }], `no collection ${name}`)
|
|
}
|
|
export const takenCollection = (): never => {
|
|
throw conflict("this collection name is taken")
|
|
}
|