Files

295 lines
11 KiB
TypeScript

// What the notes module does, as pure TypeScript over the ports in ports.ts. Every write is one
// transaction with the event that announces it, so the audit event and the usage fact of a change
// exist exactly when the change does (core/subscribers.ts reads the events).
//
// The two things this module does not own are the file and the permission to see it: an attachment
// is a row of the files module, reached through `getFile`, which answers NotFound for another
// tenant's file and for one that does not exist alike (spec §3.3).
import { conflict, forbidden, notFound, validationFailed } from "@conusai-kit/core"
import { type FileView, getFile } from "@conusai-kit/files"
import type { z } from "zod"
import {
ATTACHMENT_CONTENT_TYPES,
type AttachmentView,
attachmentAdded,
attachmentRemoved,
attachments,
MAX_ATTACHMENTS,
type NoteView,
noteCreated,
noteDeleted,
notes,
noteUpdated,
} from "../contract/index.js"
import { attachmentsEnabled, type NoteScope, type NotesDeps } from "./ports.js"
type NoteRow = z.output<typeof notes.schema>
const PAGE = 200
class AttachmentConcurrencyConflict extends Error {
constructor(readonly original: unknown) {
super("the note changed while another attachment was being added")
}
}
const ids = (scope: NoteScope) => ({
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
})
const noteView = (row: NoteRow, attachmentCount: number): NoteView => ({
noteId: row.id,
title: row.title,
body: row.body,
version: row.version,
createdBy: row.createdBy,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
attachmentCount,
})
function attachmentView(
row: { id: string; fileId: string; createdAt: string },
file: Pick<FileView, "filename" | "contentType" | "size">,
): AttachmentView {
return {
attachmentId: row.id,
fileId: row.fileId,
filename: file.filename,
contentType: file.contentType,
size: file.size,
attachedAt: row.createdAt,
}
}
export async function listNotes(
deps: NotesDeps,
scope: NoteScope,
options: { cursor?: string; limit?: number },
): Promise<{ notes: NoteView[]; cursor?: string; hasMore: boolean }> {
const repos = deps.data.forScope(scope)
const page = await repos.of(notes).list({
order: [{ field: "createdAt", direction: "desc" }],
...(options.limit === undefined ? {} : { limit: options.limit }),
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
})
// The attachment counts of the page's notes in one read over the note index, not one count per
// note (up to 200 queries for one list).
const counts = new Map<string, number>()
if (page.items.length > 0) {
let linksCursor: string | undefined
do {
const attachmentsPage = await repos.of(attachments).list({
where: [{ field: "noteId", op: "in", values: page.items.map((row) => row.id) }],
limit: PAGE,
...(linksCursor === undefined ? {} : { cursor: linksCursor }),
})
for (const link of attachmentsPage.items) counts.set(link.noteId, (counts.get(link.noteId) ?? 0) + 1)
linksCursor = attachmentsPage.hasMore ? attachmentsPage.cursor : undefined
} while (linksCursor !== undefined)
}
return {
notes: page.items.map((row) => noteView(row, counts.get(row.id) ?? 0)),
...(page.cursor === undefined ? {} : { cursor: page.cursor }),
hasMore: page.hasMore,
}
}
async function attachmentRows(deps: NotesDeps, scope: NoteScope, noteId: string) {
const found = []
let cursor: string | undefined
do {
const page = await deps.data
.forScope(scope)
.of(attachments)
.list({
where: [{ field: "noteId", op: "eq", value: noteId }],
order: [{ field: "createdAt", direction: "asc" }],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
found.push(...page.items)
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
return found
}
export async function getNote(
deps: NotesDeps,
scope: NoteScope,
noteId: string,
): Promise<{ note: NoteView; attachments: AttachmentView[]; attachmentsEnabled: boolean }> {
// NotFound for another tenant's note and for one nobody wrote: the repository answers both the same.
const row = await deps.data.forScope(scope).of(notes).get(noteId)
if (!row) throw notFound("no such note")
const [links, enabled] = await Promise.all([attachmentRows(deps, scope, row.id), attachmentsEnabled(deps, scope)])
// The files are read together, not one after another: a note carries at most MAX_ATTACHMENTS, and
// a deleted one (before the relay removed its links) is not a reason to fail the page.
const files = await Promise.all(
links.map((link) =>
getFile(deps.files, scope, link.fileId).catch((error: unknown) => {
if ((error as { status?: number }).status === 404) return undefined
throw error
}),
),
)
const views: AttachmentView[] = []
for (const [at, link] of links.entries()) {
const file = files[at]
if (file) views.push(attachmentView(link, file))
}
return {
note: noteView(row, views.length),
attachments: views,
attachmentsEnabled: enabled,
}
}
export async function createNote(
deps: NotesDeps,
scope: NoteScope,
input: { title: string; body: string },
): Promise<NoteView> {
return deps.data.unitOfWork.run(scope, async (tx) => {
const row = await tx.of(notes).insert({ title: input.title, body: input.body, createdBy: scope.actor.userId })
await tx.publish(noteCreated, { ...ids(scope), noteId: row.id, createdBy: scope.actor.userId })
return noteView(row, 0)
})
}
export async function updateNote(
deps: NotesDeps,
scope: NoteScope,
input: { noteId: string; title: string; body: string; expectedVersion: number },
): Promise<NoteView> {
return deps.data.unitOfWork.run(scope, async (tx) => {
const current = await tx.of(notes).get(input.noteId)
if (!current) throw notFound("no such note")
// The repository answers 409 when the version is not the row's: two editors never overwrite each other silently.
const row = await tx.of(notes).update(current.id, { title: input.title, body: input.body }, input.expectedVersion)
await tx.publish(noteUpdated, {
...ids(scope),
noteId: row.id,
version: row.version,
updatedBy: scope.actor.userId,
})
const count = await tx.of(attachments).count({ where: [{ field: "noteId", op: "eq", value: row.id }] })
return noteView(row, count)
})
}
/**
* Deletes the note and its links. The files stay: they are the project's, they may be attached to
* another note, and removing one is `files.file.delete`'s decision, with its own permission.
*/
export async function deleteNote(deps: NotesDeps, scope: NoteScope, noteId: string): Promise<void> {
await deps.data.unitOfWork.run(scope, async (tx) => {
const row = await tx.of(notes).get(noteId)
if (!row) throw notFound("no such note")
let removed = 0
for (;;) {
const page = await tx.of(attachments).list({ where: [{ field: "noteId", op: "eq", value: row.id }], limit: PAGE })
for (const link of page.items) {
await tx.of(attachments).delete(link.id)
removed += 1
}
if (!page.hasMore) break
}
await tx.of(notes).delete(row.id)
await tx.publish(noteDeleted, {
...ids(scope),
noteId: row.id,
deletedBy: scope.actor.userId,
attachmentCount: removed,
})
})
}
/**
* Links an uploaded file to a note. Idempotent: attaching a file twice answers with the first link
* and publishes nothing, so a retried request audits once.
*/
export async function addAttachment(
deps: NotesDeps,
scope: NoteScope,
input: { noteId: string; fileId: string },
): Promise<AttachmentView> {
if (!(await attachmentsEnabled(deps, scope))) throw forbidden("attachments are switched off")
const file = await getFile(deps.files, scope, input.fileId)
// The files module accepts more than a note should carry (spreadsheets, say); this module's list is its own.
if (!(ATTACHMENT_CONTENT_TYPES as readonly string[]).includes(file.contentType)) {
throw validationFailed([{ path: "fileId", message: `${file.contentType} cannot be attached to a note` }])
}
// The count and insert are one transaction, but that alone does not serialize two transactions
// that attach different files to the same note. Optimistically update the note first: its
// expected-version write is the portable per-note lock across SQL, MongoDB and Convex. A loser
// retries the transaction and rechecks both idempotency and the quota against the winner's row.
for (let attempt = 0; ; attempt += 1) {
try {
return await deps.data.unitOfWork.run(scope, async (tx) => {
const noteRepo = tx.of(notes)
const note = await noteRepo.get(input.noteId)
if (!note) throw notFound("no such note")
const links = tx.of(attachments)
const existing = (
await links.list({
where: [
{ field: "noteId", op: "eq", value: note.id },
{ field: "fileId", op: "eq", value: file.fileId },
],
limit: 1,
})
).items[0]
if (existing) return attachmentView(existing, file)
try {
await noteRepo.update(note.id, {}, note.version)
} catch (error) {
if ((error as { status?: number }).status === 409) throw new AttachmentConcurrencyConflict(error)
throw error
}
if ((await links.count({ where: [{ field: "noteId", op: "eq", value: note.id }] })) >= MAX_ATTACHMENTS) {
throw conflict(`a note carries at most ${MAX_ATTACHMENTS} attachments`)
}
const link = await links.insert({ noteId: note.id, fileId: file.fileId, attachedBy: scope.actor.userId })
await tx.publish(attachmentAdded, {
...ids(scope),
noteId: note.id,
attachmentId: link.id,
fileId: file.fileId,
attachedBy: scope.actor.userId,
})
return attachmentView(link, file)
})
} catch (error) {
// A retry that loses the optimistic lock re-reads the winner's link or sees the full quota.
// Bound retries so sustained contention returns the repository's ordinary conflict response.
if (!(error instanceof AttachmentConcurrencyConflict)) throw error
if (attempt >= 7) throw error.original
}
}
}
export async function removeAttachment(
deps: NotesDeps,
scope: NoteScope,
input: { noteId: string; attachmentId: string },
): Promise<void> {
await deps.data.unitOfWork.run(scope, async (tx) => {
const link = await tx.of(attachments).get(input.attachmentId)
// The note id is part of the lookup, so a link of another note of this project is not found either.
if (!link || link.noteId !== input.noteId) throw notFound("no such attachment")
await tx.of(attachments).delete(link.id)
await tx.publish(attachmentRemoved, {
...ids(scope),
noteId: link.noteId,
attachmentId: link.id,
fileId: link.fileId,
removedBy: scope.actor.userId,
})
})
}