This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
// 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// The ports the notes module's use cases are written against, and the one it adds: the feature flags.
|
||||
//
|
||||
// The audit and usage sinks and the scope of an event delivery are the kernel's own tokens
|
||||
// (`FILES_AUDIT`, `FILES_USAGE`, `FILES_SCOPE`): the worker binds them once for every module, so a
|
||||
// module that audits and meters needs no binding of its own and no line in the worker.
|
||||
|
||||
import { type Container, type TenantScope, token } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import type { FilesDeps } from "@conusai-kit/files"
|
||||
import { ATTACHMENTS_FLAG, NOTES_FLAG_DEFAULTS } from "../contract/index.js"
|
||||
|
||||
/** A project scope: the only level a note can live at. */
|
||||
export type NoteScope = TenantScope & { workspaceId: string; projectId: string }
|
||||
|
||||
export interface NotesDeps {
|
||||
readonly data: DataSource
|
||||
/** The files module's ports: an attachment is one of its files. */
|
||||
readonly files: FilesDeps
|
||||
readonly flags: NotesFlags
|
||||
}
|
||||
|
||||
/** What a flag service answers for the project a call runs in. */
|
||||
export interface NotesFlags {
|
||||
enabled(flag: keyof typeof NOTES_FLAG_DEFAULTS, scope: NoteScope): boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound by an app that evaluates flags per organization, project or user (the platform's flag
|
||||
* service, through `@conusai-sdk/node-sdk/flags`). With nothing bound, a flag is its manifest default.
|
||||
*/
|
||||
export const NOTES_FLAGS = token<NotesFlags>("notes.flags")
|
||||
|
||||
export const defaultFlags: NotesFlags = { enabled: (flag) => NOTES_FLAG_DEFAULTS[flag] }
|
||||
|
||||
export const flagsOf = (container: Container): NotesFlags =>
|
||||
container.has(NOTES_FLAGS) ? container.resolve(NOTES_FLAGS) : defaultFlags
|
||||
|
||||
export const attachmentsEnabled = async (deps: NotesDeps, scope: NoteScope): Promise<boolean> =>
|
||||
deps.flags.enabled(ATTACHMENTS_FLAG, scope)
|
||||
@@ -0,0 +1,165 @@
|
||||
// What happens exactly once per change.
|
||||
//
|
||||
// The audit event and the usage fact hang off the outbox record the changing transaction wrote, and
|
||||
// the relay delivers a record exactly once per subscriber, so a note created by a retried request
|
||||
// is audited and metered once, and one whose process died before the relay ran still is, because
|
||||
// the record committed with the row. The platform's audit and usage APIs are HTTP and cannot join
|
||||
// the transaction: each call carries a key derived from the record, so a delivery the relay repeats
|
||||
// after a crash is the same event to the platform and not a second one.
|
||||
//
|
||||
// Ids, counts and versions only: no title and no body in an audit event (spec §9.4).
|
||||
|
||||
import { type EventContext, internal } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE } from "@conusai-kit/data"
|
||||
import { auditEventId, FILES_AUDIT, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
|
||||
import {
|
||||
ATTACHMENT_ADDED,
|
||||
ATTACHMENT_REMOVED,
|
||||
attachments,
|
||||
NOTE_CREATED,
|
||||
NOTE_DELETED,
|
||||
NOTE_UPDATED,
|
||||
NOTES_CREATED_METER,
|
||||
} from "../contract/index.js"
|
||||
|
||||
interface InProject {
|
||||
orgId: string
|
||||
workspaceId: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization a delivery belongs to comes from the container -- the scope the relay rebuilt
|
||||
* from the outbox record's own `orgId` -- and is compared with the payload's: a record that named
|
||||
* another tenant is refused here rather than written there.
|
||||
*/
|
||||
function assertSameOrg(ctx: EventContext, payload: InProject): void {
|
||||
const ambient = ctx.container.resolve(FILES_SCOPE)
|
||||
if (ambient.orgId !== payload.orgId) {
|
||||
throw internal(`a notes event of organization ${payload.orgId} was delivered in ${ambient.orgId}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface AuditShape<P extends InProject> {
|
||||
action: string
|
||||
actor: (payload: P) => string
|
||||
target: (payload: P) => { id: string; type: string }
|
||||
/** What makes two events of one action on one target two events: a version, a link's id. */
|
||||
occurrence: (payload: P) => string
|
||||
data: (payload: P) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** A subscriber that writes one audit event per record. */
|
||||
export function auditing<P extends InProject>(shape: AuditShape<P>) {
|
||||
return async (event: { name: string; payload: P }, ctx: EventContext): Promise<void> => {
|
||||
const { payload } = event
|
||||
assertSameOrg(ctx, payload)
|
||||
await ctx.container.resolve(FILES_AUDIT).log(shape.action, {
|
||||
actor: { kind: "human", id: shape.actor(payload) },
|
||||
target: shape.target(payload),
|
||||
event_id: await auditEventId(shape.action, shape.occurrence(payload)),
|
||||
data: { ...shape.data(payload), projectId: payload.projectId },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type Payloads = {
|
||||
created: InProject & { noteId: string; createdBy: string }
|
||||
updated: InProject & { noteId: string; version: number; updatedBy: string }
|
||||
deleted: InProject & { noteId: string; deletedBy: string; attachmentCount: number }
|
||||
added: InProject & { noteId: string; attachmentId: string; fileId: string; attachedBy: string }
|
||||
removed: InProject & { noteId: string; attachmentId: string; fileId: string; removedBy: string }
|
||||
}
|
||||
|
||||
// The organization has to be in the target or the in-app viewer could not show one
|
||||
// organization's events without another's (apps/web/src/server/audit.ts): a resource target is
|
||||
// `<org id>/<resource id>` here, the id being what an outbox payload carries; the transport
|
||||
// writes the same rule with the slug it has, and the viewer matches either.
|
||||
const note = (payload: InProject & { noteId: string }) => ({ id: `${payload.orgId}/${payload.noteId}`, type: "note" })
|
||||
|
||||
const auditCreated = auditing<Payloads["created"]>({
|
||||
action: NOTE_CREATED,
|
||||
actor: (p) => p.createdBy,
|
||||
target: note,
|
||||
occurrence: (p) => p.noteId,
|
||||
data: () => ({}),
|
||||
})
|
||||
|
||||
const auditUpdated = auditing<Payloads["updated"]>({
|
||||
action: NOTE_UPDATED,
|
||||
actor: (p) => p.updatedBy,
|
||||
target: note,
|
||||
occurrence: (p) => `${p.noteId}:${p.version}`,
|
||||
data: (p) => ({ version: p.version }),
|
||||
})
|
||||
|
||||
const auditDeleted = auditing<Payloads["deleted"]>({
|
||||
action: NOTE_DELETED,
|
||||
actor: (p) => p.deletedBy,
|
||||
target: note,
|
||||
occurrence: (p) => p.noteId,
|
||||
data: (p) => ({ attachmentCount: p.attachmentCount }),
|
||||
})
|
||||
|
||||
const auditAdded = auditing<Payloads["added"]>({
|
||||
action: ATTACHMENT_ADDED,
|
||||
actor: (p) => p.attachedBy,
|
||||
target: note,
|
||||
occurrence: (p) => p.attachmentId,
|
||||
data: (p) => ({ attachmentId: p.attachmentId, fileId: p.fileId }),
|
||||
})
|
||||
|
||||
const auditRemoved = auditing<Payloads["removed"]>({
|
||||
action: ATTACHMENT_REMOVED,
|
||||
actor: (p) => p.removedBy,
|
||||
target: note,
|
||||
occurrence: (p) => p.attachmentId,
|
||||
data: (p) => ({ attachmentId: p.attachmentId, fileId: p.fileId }),
|
||||
})
|
||||
|
||||
/** One audit event and one usage fact per note created. */
|
||||
export async function onNoteCreated(event: { name: string; payload: Payloads["created"] }, ctx: EventContext) {
|
||||
await auditCreated(event, ctx)
|
||||
const usage = ctx.container.resolve(FILES_USAGE)
|
||||
await usage.track({
|
||||
customer: { externalId: event.payload.orgId },
|
||||
meter: NOTES_CREATED_METER,
|
||||
quantity: 1,
|
||||
idempotencyKey: `${NOTES_CREATED_METER}.${event.payload.noteId}`,
|
||||
})
|
||||
// Awaited: a sink that sends per call is then durable before the relay marks the record published.
|
||||
await usage.flush()
|
||||
}
|
||||
|
||||
export const onNoteUpdated = auditUpdated
|
||||
export const onNoteDeleted = auditDeleted
|
||||
export const onAttachmentAdded = auditAdded
|
||||
export const onAttachmentRemoved = auditRemoved
|
||||
|
||||
/**
|
||||
* A file that stopped being stored takes its links with it, so a note never lists an attachment
|
||||
* nobody can open. The scope is the delivery's own organization and a system actor with the two
|
||||
* deeper ids of the record the files module wrote in that organization's transaction.
|
||||
*/
|
||||
export async function onFileDeleted(
|
||||
event: { name: string; payload: InProject & { fileId: string } },
|
||||
ctx: EventContext,
|
||||
): Promise<void> {
|
||||
const { payload } = event
|
||||
assertSameOrg(ctx, payload)
|
||||
const ambient = ctx.container.resolve(FILES_SCOPE)
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
actor: ambient.actor,
|
||||
orgId: ambient.orgId,
|
||||
workspaceId: payload.workspaceId,
|
||||
projectId: payload.projectId,
|
||||
}
|
||||
await ctx.container.resolve(DATA_SOURCE).unitOfWork.run(scope, async (tx) => {
|
||||
for (;;) {
|
||||
const page = await tx.of(attachments).list({ where: [{ field: "fileId", op: "eq", value: payload.fileId }] })
|
||||
for (const link of page.items) await tx.of(attachments).delete(link.id)
|
||||
if (!page.hasMore) break
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user