295 lines
11 KiB
TypeScript
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,
|
|
})
|
|
})
|
|
}
|