// 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 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, ): 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() 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 { 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 { 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 { 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 { 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 { 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, }) }) }